30 秒判断:Agent 说“我调用了工具”,不等于工具真的执行过;模型在文本里写下一条
Observation,也不等于系统得到了真实工具结果。只要 Harness 把这段文本原样放回下一轮上下文,模型虚构的“事实”就可能变成后续推理的依据。提示词能要求模型遵守协议,但只有 Harness 能保证协议真的被执行。适合谁读:正在实现 ReAct、tool calling 或多轮 Agent 的开发者;已经跑通工具调用,但开始遇到“回答看似自信、证据却对不上”的人;以及正在建设 Agent trace、评测和回归体系的人。
系列位置:本文属于《学习问答助手Agent实践》的实现复盘部分。上一篇《一个合法 HTML 注释,为什么能让 RAG 入库失败?》讨论不可信输入如何击穿解析器边界;本篇讨论另一种更隐蔽的不可信输入:模型自己生成的工具结果。
结构速览:§0 异常现场;§1 trace 里的缺口;§2 一段文本如何被当成事实;§3 为什么提示词解决不了;§4 修复必须切开的三个边界;§5 回归测试;§6 对 Agent 工程的启示;§7 一页速查。
证据标记:✅ 项目实测=真实运行或真实 trace;🧪 可回归=已有确定性测试固定行为;🤔 当前判断=由当前机制推导出的结论,不等同于已测得的发生率。
0. 同一道题,8 次“有”,2 次“没有”
在评测中,同一个用例被重复执行了 10 次。题目本身很简单:
Anthropic 的《How we contain Claude》里提到过 MCP 吗?
正常路径应该是:
模型提出 Action
↓
Harness 真正执行 scan_term(term="MCP")
↓
工具返回真实结果
↓
Harness 生成 Observation
↓
模型根据 Observation 回答
这次正式运行里,10 个 attempt 中有 8 个走了正常路径。它们都真实调用了 scan_term,每次得到的命中数都是 3。
另外 2 个 attempt 的最终答案却说:
MCP 在文中出现了 0 次。
如果只看最终答案,很容易把它解释成模型不稳定:同一道题,有时答对,有时答错。但顺着 trace 往下看,问题不是“检索结果偶尔不同”,而是有两个执行样本根本没有拿到检索结果。
✅ 项目实测:正式运行
run_861667d15e764d84b9484dff51cd162e中,8 个正常样本真实检索到 3 个命中;2 个失败样本最终声称命中数为 0。
这也是 Agent 评测和普通文本评测的重要区别。只比较最终答案,只能看到“答错了”;保存完整 trace,才能继续问:
它为什么这么答?
它掌握了什么证据?
它以为它掌握了什么证据?
1. trace 里的缺口:模型说执行过,但工具没有执行
两个失败 attempt 中,有一个尝试过一次 list_documents,但没有任何真实的 scan_term span。另一个连真实工具调用都没有。
正常 trace 里应该分别出现:
- 模型提出
scan_term的 Action; - Harness 派发工具;
- 工具自身产生的 span 和耗时;
- 工具返回的真实 payload;
- Harness 写入的 Observation。
失败 trace 里,这些执行事实不存在。模型输出中却出现了类似下面的内容:
Action: scan_term[{"term":"MCP"}]
Observation: {"count":0,"hits":[],"tool":"scan_term"}
它先写了一个准备执行的 Action,又替系统写了下一次工具结果,然后继续生成后续 Thought,最后甚至输出了 Finish。
从文本表面看,这段内容非常像一个完整的 ReAct 回合:
Thought → Action → Observation → Thought → Finish
从系统执行事实看,它只经历了一个阶段:
模型生成了文本
没有工具派发,没有工具 span,也没有 Harness 生成的 Observation。
✅ 项目实测:失败 attempt
attempt_f736af93cc9345698574f56212337554唯一一次真实工具调用是list_documents,没有真实scan_term执行。
2. 一段模型文本,怎么变成了后续推理的“事实”
这里需要区分三个对象:
| 对象 | 谁产生 | 代表什么 |
|---|---|---|
| Action | 模型 | 请求系统执行什么 |
| Observation | Harness | 系统实际得到什么结果 |
| Answer | 模型 | 面向用户的最终表达 |
Action 是提议,不是执行记录。Observation 是执行事实,不是模型可以补写的旁白。
但旧实现存在一个危险细节:模型违反协议时,Harness 已经正确识别出 protocol_error,却仍然把模型的原始输出追加到了下一轮 History。
于是下一轮模型看到的上下文近似于:
Assistant: Action: scan_term...
Assistant: Observation: {"count":0,...}
这段内容本身带有 Observation: 标记。即使 Harness 的事件系统知道它是协议错误,下一轮模型看到的仍然是一段“很有工具结果味道”的历史文本。
它可能无法可靠区分:
这是系统拒绝的非法响应?
还是上一轮工具真的返回了 0?
一旦“0 个命中”进入后续上下文,错误前提就开始接管推理。模型不是因为检索到了 0 而回答 0,而是因为它先虚构了 0,又把虚构结果当成了后续推理的输入。
这就是上下文污染。它比一次普通的协议解析失败更隐蔽,因为:
- 协议错误已经被记录;
- 原始输出也存在;
- 下一轮模型仍然会回答问题;
- 最终答案看起来像“检索没有结果”;
- 只看错误码或最终答案,很容易把根因归错。
🧪 可回归:修复测试会构造一条伪造
"count":0的模型响应,并断言这段内容没有进入下一次模型提示词。
3. 为什么“在提示词里强调不要伪造”还不够
系统提示词已经写明:
Observation 由系统执行 Action 后追加,不得自行编造或输出 Observation。
这句话仍然必要,因为它向模型解释了协议。但它不能作为系统的最后一道防线。
原因是 Agent 面临的输入和普通后端不同:
- 模型输出不是强类型函数返回值,而是需要解析的文本;
- 同一个模型可能在不同上下文中产生不同格式;
- 一次协议违规不会自动变成编译错误;
- 模型可能同时输出多个阶段,形成一个看起来自洽的假回合;
- 后续模型调用本身也会把历史当成高可信上下文。
所以这里存在两个不同层次的保证:
提示词:要求模型尽量遵守协议
Harness:保证系统只接受真实执行产生的事实
前者影响概率,后者定义边界。
如果只有提示词,Harness 的职责就会退化成“相信模型的自述”。而安全边界不能建立在被约束对象的自述上。
🤔 当前判断:模型生成伪造 Observation 时,不应推断它“有意欺骗”。更准确的描述是:模型在当前上下文中越过了协议边界,把一个预测出来的工具结果当成了已经发生的事实。
4. 修复:把执行权、上下文权和审计权切开
修复没有选择“遇到协议错误就重试一次”这种局部办法。重试可能掩盖问题,却不会改变上下文权威。
最终交给三个不同组件:
4.1 执行权只属于 Harness
模型只能提出 Action。只有 Harness 完成协议解析、参数校验、工具派发和结果封装后,才能写入新的 Observation。
模型文本中出现自己的 <observation> 或行级 Observation:,会直接被拒绝为协议错误。
4.2 上下文权只接受可验证事实
协议错误响应不再原样进入下一轮 History。
History 中只保留一条明确占位提示:
上一条响应格式错误,原文已由 Harness 省略。
请重新按协议输出一个 Thought 和一个 Action,不得编造 Observation。
关键是“原文已省略”。这样下一轮模型知道上一轮失败了,但看不到伪造的 count=0,也就不能把它继续当成证据。
4.3 审计权保留完整原始输出
隔离上下文不等于销毁证据。
模型的原始响应仍然保存在 trace artifact 中,可以回答:
- 当时模型到底输出了什么;
- 它是在哪个字段伪造了 Observation;
- 协议错误的具体解析原因是什么;
- 修复前后的行为是否真的变化。
三者的关系可以概括为:
原始输出 → 只进入审计 artifact
可验证执行结果 → 才能进入下一轮 History
用户答案 → 只能来自模型最终 Finish,不能混入 Action 或 Observation
这比“把错误响应删掉”更稳妥。简单删除会让故障无法复盘;原样保留又会污染上下文。正确做法是分仓保存:审计需要原文,推理只接收受控事实。
同时,Agent Loop 版本从 react-langgraph-v1 升级为 react-langgraph-v2。这不是装饰字段,而是让 trace 能区分修复前后的运行语义。
✅ 项目实测:真实 v2 探针
run_14ee6d1b673f4f00ae32f36d8a1ddcb8的 trace 标记为react-langgraph-v2和chat-v3,真实执行了list_documents、scan_term、list_blocks,没有出现协议错误。
5. 回归测试要证明“污染没有发生”
只测试“协议错误被识别”还不够。旧实现已经能识别错误,却仍然存在上下文污染。
更有价值的断言应该直接检查下一次模型输入:
第二次调用可以看到 protocol_error
第二次调用不能看到 count: 0
第二次调用不能看到 forged
第二次调用必须看到“原文已省略”
这类测试把修复目标从“事件存在”推进到了“错误信息没有越界进入下一轮推理”。
还需要一个真实运行探针,因为 fake provider 只能证明控制流,不能证明真实模型、真实协议和真实 trace 能协同工作。v2 探针的价值就在这里:它确认真实工具 span、loop version、prompt version 和协议行为都在同一条可审计链路上。
完整回归至少需要三层:
| 层级 | 证明什么 |
|---|---|
| 单元测试 | 伪造 Observation 不会进入下一轮 History |
| 真实单样本探针 | 新版本能在真实 Agent 链路上完整运行 |
| N/K 正式评测 | 多 attempt 聚合后,产品语义和成本边界没有被破坏 |
🧪 可回归:当前确定性测试已经覆盖第一层;v2 单样本探针覆盖第二层;正式 N/K 运行继续承担第三层观察。
6. 对 Agent 工程的启示
这个案例最有意思的地方,不是“模型居然会犯这种错”,而是它同时暴露了 Agent 系统的两个常见误区。
6.1 把模型输出当成可信程序输出
模型输出首先是一段外部输入,必须经过解析、校验和权限边界。
无论它看起来多么像 JSON、函数调用或工具结果,都不能绕过 Harness 直接改变系统状态。
判断一个 Agent 是否真正有 Harness,不应看它有没有 tools 参数,而应问:
工具是谁执行的?
Observation 是谁写的?
模型文字能否伪造执行事实?
非法输出去了哪里?
下一轮上下文接收的是原文还是受控状态?
6.2 把可观测性当成上下文隔离
trace 能记录问题,不代表问题不会影响下一轮推理。
一个系统完全可能同时做到:
- 正确记录
protocol_error; - 正确保存原始输出;
- 然后错误地让原始输出继续进入 History。
可观测性的目标是“事后能看见”,上下文隔离的目标是“错误事实不能继续参与推理”。两者互补,但不能互相替代。
6.3 概率系统更需要确定性防线
大模型让 Agent 能够处理开放任务,也让同一条协议在不同运行中出现偏差。
Harness 工程不是给模型再加一层“更聪明的提示词”,而是把不可靠的生成过程包进可验证的执行结构:
模型负责提出候选行为
Harness 负责验证、执行与记账
Trace 负责保存执行事实和原始现场
评测负责观察随机行为是否越过产品边界
在这个分工里,模型可以犯错;系统不能把错误悄悄升级成事实。
7. 一页速查
现象:同一个检索问题运行 10 次,8 次真实得到 3 个命中,2 次没有执行 scan_term,却回答“0 个命中”。
直接原因:模型在一条响应中自行写出了 Action 和 Observation,Harness 将整条响应识别为 protocol_error。
真正造成污染的旧行为:协议错误响应虽然被记录,却仍然被原样追加到下一轮 History,使模型生成的 count=0 看起来像真实工具结果。
修复原则:
- 模型只能提出 Action;
- 只有 Harness 能派发工具并生成 Observation;
- 协议错误原文只进入 trace artifact,不进入下一轮推理上下文;
- History 只收到“原文已省略、不得伪造”的受控提示;
- 用确定性测试断言伪造结果没有进入下一次 prompt;
- 用 loop version 区分修复前后语义。
最重要的一句话:
Agent 可以说“我调用了工具”,但只有 Harness 能证明工具真的执行过;模型输出是待验证的输入,系统执行记录才是事实。