模型会自己伪造工具结果:一次 Agent 评测暴露的 Harness 工程问题——从一句 `count=0`,看模型输出、工具执行和上下文权威的边界

5 阅读12分钟

30 秒判断:Agent 说“我调用了工具”,不等于工具真的执行过;模型在文本里写下一条 Observation,也不等于系统得到了真实工具结果。只要 Harness 把这段文本原样放回下一轮上下文,模型虚构的“事实”就可能变成后续推理的依据。提示词能要求模型遵守协议,但只有 Harness 能保证协议真的被执行。

适合谁读:正在实现 ReAct、tool calling 或多轮 Agent 的开发者;已经跑通工具调用,但开始遇到“回答看似自信、证据却对不上”的人;以及正在建设 Agent trace、评测和回归体系的人。

系列位置:本文属于《学习问答助手Agent实践》的实现复盘部分。上一篇《一个合法 HTML 注释,为什么能让 RAG 入库失败?》讨论不可信输入如何击穿解析器边界;本篇讨论另一种更隐蔽的不可信输入:模型自己生成的工具结果。

代码库:ai-study-helper-v2

结构速览:§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模型请求系统执行什么
ObservationHarness系统实际得到什么结果
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 能证明工具真的执行过;模型输出是待验证的输入,系统执行记录才是事实。