剥离了框架的黑盒后,上一篇我们已经把天气服务通过 MCP 接入了系统。但随之而来的棘手问题在于:评测链路至今仍只能靠人工肉眼 Review 日志。只盯着大模型的”终答”(Final Answer)——比如一句”已订钟楼索菲特 suite”——在传统答案级评测里就算通过了;可一旦透视底层状态机流转,它很可能压根没调用天气 API 就直接触发了订房,传入的 hotel_id 甚至纯粹是大模型幻觉(Hallucination)编造的产物。答案级评测过了,不代表底层没问题——绕了十轮才碰巧撞对,与一轮精准命中目标,在工程意义上完全是两码事。
本篇我们依然坚持”手搓”原则,不引入任何重型框架,保持核心调度循环(Loop)、记忆(Memory)和计划(Plan)机制不变。核心思路是:从 messages 上下文中抽离出 tool_calls 作为执行轨迹(Trace),再针对这些轨迹跑确定性的规则检查(Check)。为了规避大模型幻觉的干扰,我们先用预先”糊”好的对照轨迹来验证检查逻辑本身,全程不裸调 API 做模型推理;验证通过后,再把真实执行一次 run 产出的 messages 喂进同一套检查流水线。
测试目标依然明确:预订西安 10 月 1 日的两晚酒店,下雨则订 suite(套房),否则订 standard(标准间)。测试环境中的天气是本地 Mock 数据,设定 10 月 1 日为雨天。
在深入架构细节之前,为了避免概念混淆,先把下文的高频术语”打平”对齐(全文仅按此定义使用):
| 术语 | 工程语义 |
|---|---|
| 终答 | 调度出口处,没有 tool_calls 时 Assistant 角色输出的 content |
run | 目标进入核心循环到最终跳出出口的完整状态机流转过程 |
| 轨迹 | 一次 run 中按时间序列排开的 tool_calls 及其回灌数据 |
| 检查 | 针对轨迹执行的一条确定性规则,结果是严格的布尔值(过/不过) |
| 答案级 | 评测仅扫描终答文本的关键字 |
| 轨迹级 | 评测深入底层,对执行轨迹运行检查规则 |
| 对照轨迹 | 预先”糊”好的 messages 数组,专用于验证检查逻辑,绝对不调模型 |
| 真跑 | 进入核心循环调用模型走完一次 run,再将产物透传给同一套流水线判决 |
| 跳步 | 业务工具调用拓扑顺序错误,例如未查天气直接订房 |
| 参数来源 | hotel_id 必须严格溯源自前置 search_hotels 的回灌数据 |
计划、步骤、业务工具、hotel_id 白名单等概念,工程语义与前文保持一致。
核心架构铁律:评测必须深挖执行轨迹,绝不能仅看终答。所有检查逻辑必须收敛为确定性的代码,严禁在评测环节引入大模型做二次判决。
Table of contents
Open Table of contents
一、终答通过,不等于轨迹级验证通过
相比套壳调用,答案级评测在 Agent 底层开发里欺骗性极大——它通常只盯终答文本里有没有”订”字、房型关键字对不对。以名为 skip 的对照轨迹为例,它的终答与完全正确的 ok 轨迹一模一样:
西安 10 月 1 日下雨,已订钟楼索菲特 suite,两晚 800 美元。
只做答案级验收,这条轨迹堪称完美;可一旦透视底层的工具调用拓扑,就会发现系统第一个业务工具就直接裸调了 book_hotel,前置的 get_weather 和 search_hotels 根本没被触发。
这类跳步和幻觉问题,我们在前期开发中早已屡见不鲜,当时只能靠开发者人肉核对每一轮打出的工具名称和回灌数据。要彻底解决这一痛点并实现工程化,就必须把这套检查逻辑沉淀为可执行代码,从而对批量化构建的对照轨迹、以及真实并发调度跑出来的 messages 都能做自动化回归测试。
这里必须强调一条工程底线:绝对不要在检查环节再次调用大模型。 这不仅会烧掉高昂的 Token 成本,更致命的是,大模型在评测时同样会产生幻觉或拒答,这与我们试图捕获的被测误差属于同源污染。再者,模型在黑盒状态下根本无法感知系统内部的 hotel_id 白名单,也无法精准核对上下文回灌中的 ID 映射关系。
二、从上下文 Messages 中无损抽离轨迹
既然不能依赖终答,那就必须深入到底层执行链路中去。为了解决上下文膨胀的问题、同时保持系统简洁,我们并不需要在内存里另存一份独立的轨迹副本。核心调度循环本来就会把每一轮的 assistant.tool_calls 和 role=tool 的执行结果回灌进 messages 数组;评测模块只需做一层简单的映射配对——只要 tool_call_id 能精准对齐,就能还原出完整的执行轨迹。
def extract_calls(messages):
by_id = {m["tool_call_id"]: m for m in messages if m.get("role") == "tool"}
calls = []
for msg in messages:
if msg.get("role") != "assistant":
continue
for tc in msg.get("tool_calls") or []:
args = json.loads(tc["function"]["arguments"])
result = json.loads(by_id[tc["id"]]["content"])
calls.append(Call(tc["function"]["name"], args, result))
return calls
完成 AST 解析级别的轨迹抽离后,评测流水线将彻底抛弃终答。大模型生成的 content 只作为日志留给人类开发者 Debug,不再参与任何自动化检查链路。
相比套壳调用,这种底层架构的优势在于:即便是接入了 MCP 跑出来的 messages,同样能无缝抽离。在轨迹视角下,外部 MCP 工具和本地原生工具在数据结构上已经被完全打平,检查逻辑依然只关注工具名称、入参以及回灌结果。我们刻意设计了不调模型、不连 MCP 的对照轨迹,正是为了确保检查逻辑的纯粹性——让判定结果绝对不依赖模型某次充满随机性的输出。
三、将检查逻辑沉淀为确定性代码
针对前期暴露的系统失效场景,我们抽象出四条核心检查规则。每条规则执行后都会返回明确的布尔值(过/不过),并附带一条精准的诊断原因。
| 检查维度 | 通过条件 |
|---|---|
| 计划优先 | 第一个业务工具被调用前,必须存在 set_plan 节点 |
| 跳步拦截 | 严格遵循 get_weather → search_hotels → book_hotel 的调用拓扑 |
| 参数溯源 | book_hotel 下发的 hotel_id 必须存在于此前 search_hotels 的回灌中 |
| 房型对齐 | 若 get_weather 返回 rain 则必须订 suite,返回 clear 则必须订 standard |
在具体工程实现中,跳步检查严格关注业务工具的调用拓扑(Topology)。如果系统中间插入了 mark_step 等状态流转工具,既不算跳步,也不算完成该业务节点。若是为了兜底(Fallback)而用不同参数重试同一个步骤,只要计划没发生变更,检查状态也维持原样——这是系统容错机制下的合法重试,而非跳步。
参数来源检查则坚决不读取大模型的记忆。记忆本质上是大模型自行总结的中间态结论,随时可能被篡改或产生幻觉;评测系统只信任底层真实的回灌数据。因此,合法的 hotel_id 必须严格从前置 search_hotels 节点返回的 tool 角色 content 中提取,实现严格溯源。
同理,房型检查也绝不理会终答里天花乱坠的描述,而是直接对齐 book_hotel 实际下发的参数与 get_weather 真实回灌的天气状态。
def check_hotel_id_source(calls):
allowed = []
for c in calls:
if c.name == "search_hotels" and "error" not in c.result:
allowed += [h["id"] for h in c.result.get("hotels") or []]
if c.name == "book_hotel" and c.args.get("hotel_id") not in allowed:
return CheckResult("参数来源", False, f"用了 {c.args.get('hotel_id')}")
return CheckResult("参数来源", True, "hotel_id 来自本次 search_hotels")
四、五组对照轨迹的沙盘推演
为了验证检查逻辑的鲁棒性,我们构造了五组目标完全相同的对照轨迹。如果只做答案级评测(只扫描终答里是否包含”订”和正确房型),这五条会全部蒙混过关;但在我们严苛的轨迹级评测下,只有 ok 这一条能真正存活。
| 对照轨迹 | 轨迹底层实际发生了什么 | 答案级 | 轨迹级 |
|---|---|---|---|
ok | 严格执行 set_plan,随后依次查询天气、搜索,最终预订 HT-002 / suite | 过 | 过 |
skip | 终答与 ok 完全一致,但首个业务工具直接裸调了 book_hotel | 过 | 跳步、参数来源、房型全部不过 |
fabricated_id | 正常执行了搜索,但最终预订了幻觉编造的 HT-999 | 过 | 参数来源不过 |
wrong_room | 天气回灌为下雨,却错误地下发了 standard 参数 | 过 | 房型不过 |
no_plan | 业务工具拓扑顺序正确,但缺失了前置的 set_plan 动作 | 过 | 计划优先不过 |
在这组沙盘推演中,skip 轨迹最能暴露传统评测的盲区:它在答案级表现得与 ok 轨迹毫无二致,但在底层轨迹级,跳步、参数来源、房型这三条防线会瞬间将其击穿。而 wrong_room 轨迹的终答虽然狡猾地写着”已订 standard”,在答案级依然能判定通过,但房型检查会敏锐捕捉到天气回灌数据与下发参数的冲突,从而将其无情拦截。
需要注意的是,即便这四条检查全部通过,系统在真实运行中依然可能出现搜了两遍酒店、重试了三次 API 的情况。这属于并发调度轮数控制和累计 Token 消耗的性能范畴,而非轨迹级正确性问题,我们会在下一篇架构演进中专门解决。
五、对照轨迹验证逻辑,真跑复用同一套流水线
对照轨迹本质上是我们手工”糊”好的、静态的 messages 数组,核心使命是测试”检查逻辑”本身是否健壮:它必须无情拦截 skip,并让 ok 顺利放行。如果检查代码写出了 Bug,静态对照会第一时间将其暴露,避免污染后续的动态测试。
静态验证无误后,再启动真跑。系统完成一次完整的状态机流转后,将出口处落盘的 messages 喂进同一个 extract_calls 解析器。整个检查过程依然坚决不调用模型——在这里,大模型的唯一职责就是在沙盒里跑出一条真实轨迹,而这条轨迹是否合法,生杀大权完全掌握在我们的确定性代码手中。
工程实践中,我们往往会对同一个目标做多次并发调度。当模型 Temperature 参数不为 0 时,跳步率必然出现波动。我们真正关心的是通过率的统计分布,而不是某一次单次运行的终答写得多漂亮。盲目更换模型或许能让终答看起来更像人类,但底层轨迹级的致命错误往往依然潜伏。
此外,后续基于 run_id 落盘持久化的 messages 日志,同样接入这条流水线。需要特别警惕的是:千万不要拿经过上下文截断或压缩后的历史副本去跑检查——原始的回灌报文一旦丢失,参数溯源将彻底失去对齐依据,导致大面积误报。
六、异常排查指南:当预期与实际对不上时
造轮子的过程中,遇到 Case 挂掉是常态。排查时请严格遵循以下优先级:
| 异常现象 | 核心排查动作 |
|---|---|
| 答案级过、轨迹级不过 | 这是架构设计的预期行为。绝对不要先去改终答。 |
| 跳步不过 | 检查 System Prompt:是否明确限制了每轮只推进一个 pending 状态;核对计划是否成功写入记忆。 |
| 参数来源不过 | 审查 hotel_id 白名单逻辑;优化 book_hotel 的 Description 描述。 |
| 房型不过 | 确认天气状态是否成功写入记忆;检查订房时房型参数是否严格按照 condition 动态填充。 |
| 计划优先不过 | 强化路由分发逻辑:没有全局计划时,坚决熔断业务工具的调用权限。 |
| 配对失败,抽不出轨迹 | 检查是否漏存了 Assistant 的 tool_calls 节点,或者 tool_call_id 发生错位。 |
| 真跑不过、对照过 | 优先 Dump 这一次 run 的真实轨迹进行排查,不要急于修改检查代码。 |
| 检查全过,但绕了很多轮 | 这不属于本篇讨论的轨迹正确性范畴,而是关乎 Token 成本与调度效率。 |
排查的核心心法是:根据报错现象去修改对应架构层的代码,绝对不要遇到问题就先想着换个大模型。换模型往往只是掩盖了表象,底层的轨迹级漏洞依然在暗处潜伏。
完整代码实现
由于检查模块本身不参与核心循环,也不调用模型,其完整实现非常轻量。源码已提交至 agent-in-action/part1-handmade-agent/06-agent-eval/eval.py。
执行 python eval.py 即可运行五条对照轨迹的沙盘推演。你会清晰地看到两列输出:答案级五条全绿通过,而轨迹级只有 ok 亮起绿灯。如果你尝试篡改 skip 的终答文本,答案级可能会随之挂掉,但底层的跳步检查依然稳如泰山,不受任何影响。
执行 python eval.py run.json 则进入真跑验证链路:入参是一次真实 run 落盘留下的 messages,系统会用同一套检查逻辑进行判决。在后续的上线篇中,根据 run_id 记录的审计日志也可以直接透传进来进行回放:
python eval.py ../08-agent-deployment/audit/run-confirm.json
那份审计日志是并发调度与断点续跑的对照组,由于缺乏 set_plan 且未查询天气,因此计划优先、跳步、房型三项检查将全部亮红,而我们的检查代码无需做任何修改。
得益于底层数据结构的打平,外部 MCP 工具和本地原生工具在轨迹中没有任何差异,接入 MCP 后的 messages 同样可以无缝丢进 extract_calls 进行解析。
架构演进预告
在彻底攻克了轨迹级正确性判定之后,下一篇我们将直面工程化中最棘手的问题:误差累积、系统护栏(Guardrails)以及累计 Token 的上下文压缩。即使轨迹检查全绿,系统依然可能陷入死循环打转,或者将庞大的历史 messages 反复透传重发。这些关乎系统到底”执不执行”、“何时熔断停机”的调度策略,已经超出了轨迹正确性的范畴,我们将另起篇幅深入探讨。
核心架构总结
评测必须深挖执行轨迹,绝不能仅看终答。通过从
messages中无损抽离tool_calls,我们将检查逻辑彻底沉淀为确定性的代码:计划优先、跳步拦截、参数溯源、房型对齐。严禁在检查环节二次调用模型。坚持用静态对照轨迹验证检查逻辑,再将真实的
run产物透传进同一套流水线。当答案级通过而轨迹级被拦截时,就证明我们的底层评测架构正在精准地发挥效用。