从零构建 Coding Agent
English
本章目录

16. 评测、调试与最终项目

Agent 开发最大的错觉是“刚才那次看起来能跑,所以它是对的”。模型输出不稳定,真实仓库复杂,工具和上下文状态很多。如果没有评测和调试体系,你会在每次改 prompt 或工具描述后重新手工试一遍,而且无法判断退化来自哪里。

三层测试

教学项目至少需要三层测试:

  • 单元测试:工具参数校验、路径解析、输出截断、写队列、换行和 BOM 处理。
  • Loop 测试:faux provider 驱动 tool use、错误回传、steering、压缩、abort。
  • 回放测试:读取一段会话日志,重建上下文,验证投影结果。

真实模型端到端测试只做 smoke。它可以告诉你系统能连通,但不适合作为主要回归测试。主要回归必须确定、便宜、可重复。这也是为什么前面所有章节都把 faux provider 和事件流当一等公民:可测试性不是最后补的,而是从协议层就设计进去的。机器可读模式(print / JSON)本身就是天然的评测入口——喂一段输入,断言输出的事件序列和退出码。

Harness 竞态测试

普通 Loop 测试会验证“输入 A 得到事件 B”,但 Harness Engineering 最容易坏的是时间关系。测试要用 deferred promise 主动把 provider、工具或 listener 卡在精确边界,再从另一条异步链触发变更。至少覆盖这些 invariant:

  • 当前 provider request 使用固定 turn snapshot;运行中切换 model、tools、resources 或 system prompt 只影响下一安全点后的请求。
  • 第二个结构性操作在 busy 时稳定拒绝;steering、follow-up 与 next-turn 进入各自队列,不能共享一个含糊的 pending input 数组。
  • Agent 消息先持久化,message_end listener 产生的 session entry 后持久化;Promise 的完成顺序不能改变日志顺序。
  • waitForIdle() 等待最后一个异步 listener 与 pending write,不在 provider stream 结束时提前返回。
  • abort 最终恢复 idle,并按契约清理或保留各个队列;失败清理也不能吞掉尚未写入的事实。

这类测试不要使用固定 sleep(100)。测试应持有显式 barrier:先断言 harness 尚未 idle,再释放 listener,最后等待完成 promise。这样失败说明契约真的被破坏,而不是 CI 机器刚好比较慢。

会话回放

会话日志天然是调试材料。一次失败任务可以导出为 JSONL,测试 harness 读取后重建 active branch,断言:

  • 上下文里是否包含该有的系统规则。
  • 压缩摘要是否保留关键事实。
  • 最后一个 tool result 是否正确进入下一轮。
  • 模型切换是否影响后续请求。
  • 分支 leaf 是否指向预期 entry。

这比截屏或人工描述 bug 更可靠。回放测试还有一个额外价值:它是格式迁移的安全网。第八章说过日志格式会演化,把真实的旧格式会话文件留作测试样本,每次迁移后回放一遍,就能保证老用户的历史不会在升级中损坏。

成本与性能

Agent 应该统计每次模型请求的 usage、耗时、工具耗时和重试次数。没有这些数据,你无法回答“为什么这个任务花了这么久”或“哪个模型最贵”。成本信息可以作为事件和 session entry 记录,不必每次都进入模型上下文。

常见指标:

  • input tokens。
  • output tokens。
  • cache read/write tokens。
  • reasoning tokens。
  • provider latency。
  • tool latency。
  • retry count。
  • compaction count。
  • estimated cost。

缓存和推理 token 值得单列。缓存读通常远比普通输入便宜,一个 system prompt 前缀稳定、缓存命中率高的 Agent,成本可能是命中率低的同类系统的几分之一——这直接呼应第十一章“稳定内容放前面”的组装顺序。推理 token 是输出的一部分但单独计价,不统计就会低估成本。

把成本落成一个具体公式会更清楚。每次 assistant 响应带回一组 usage,乘以模型目录里的单价即可:

turn_cost =
    input_tokens       * price_input
  + output_tokens      * price_output
  + cache_read_tokens  * price_cache_read     // 约为 price_input 的 0.1 倍
  + cache_write_tokens * price_cache_write
  + reasoning_tokens   * price_output          // 推理 token 按输出计价

举个例子:一次 turn 命中缓存 40k token、新输入 2k、输出 500、推理 800。如果把那 40k 缓存按普通输入价计算,成本几乎翻倍;按 0.1 倍计算,缓存部分只相当于 4k 普通输入。这就是为什么“稳定前缀加高缓存命中率”能把长会话的账单压到几分之一。也正因如此,usage 必须分五类字段记录,而不是只存一个总数——总数一旦记下,就再也拆不出缓存和推理各占多少了。

最终项目:tiny-agent

最终项目不是复刻某个现成产品,而是证明你理解 Agent 工程边界。选择一个小型 TypeScript 仓库,让 tiny-agent 完成一次真实修改:

  1. 读取用户目标。
  2. 搜索相关文件。
  3. 读取目标文件。
  4. 精确编辑实现。
  5. 运行指定检查命令。
  6. 根据失败继续修正。
  7. 生成最终摘要。
  8. 写入完整会话日志。
  9. 退出后恢复会话,说明刚才做了什么。

验收时不要只看最终代码是否正确,还要看过程是否可审计。

Capstone 验收清单

你的 tiny-agent 需要满足:

  • Provider 可替换,至少有真实 provider 和 faux provider,错误能分类为可重试与不可重试。
  • Tool call 由消息内容驱动,不靠硬编码步骤,也不因 stop reason 丢掉工具。
  • 工具参数校验失败会回喂模型,多余字段被规整而非硬拒。
  • read/edit/write/bash 都有路径边界和截断策略,edit 有规范化匹配和换行保留。
  • 写操作有 diff details 和模型摘要分离,同文件写操作按真实路径排队。
  • 会话日志是追加式 JSONL,带 parentId 树结构和版本号。
  • 恢复后不重新执行历史工具。
  • 压缩不删除历史,只改变上下文投影,且能在上下文溢出时自动重试。
  • steering 和 follow-up 有独立队列和明确的消费时机。
  • harness 为每次 provider request 建立稳定 turn snapshot,运行中配置变更只在下一安全点生效。
  • 结构性操作在 busy 时拒绝,pending session writes 按 Agent 消息之后的确定顺序落盘,waitForIdle() 不提前返回。
  • JSON 模式 stdout 只输出机器可读事件,退出码有约定。
  • 权限门和项目信任在所有运行壳中一致生效。
  • faux provider 测试覆盖至少一个工具错误自我修正场景。

调试手册

当 Agent 行为异常时,按这个顺序查:

  1. 看 session log,确认事实是否写入。
  2. 看 harness timeline,确认 phase、turn snapshot、save point、pending writes 和 settled 的顺序。
  3. 看 context projection,确认发给模型的内容是否正确(第十一章的组装调试视图在这里最有用)。
  4. 看 provider adapter,确认 stop reason 和 tool call 是否转换正确,跨模型时 id 和 thinking 是否处理妥当。
  5. 看 tool result,确认错误是否可修正、是否可行动。
  6. 看 system prompt,确认规则顺序和冲突。
  7. 最后再怀疑模型本身。

这条顺序很重要。很多“模型不听话”的问题,其实是上下文投影漏了约束,或者工具错误写得不可行动。把责任先落在自己能控制的确定性层,再落到不确定的模型,调试效率会高得多。

结束语

构建 Coding Agent 的核心不是找到一段神奇 prompt,而是把一个不确定的模型放进确定的工程边界里:协议清晰,工具受控,状态可恢复,事件可观察,权限可审计,扩展有边界,并由 harness 把所有变化限制在可解释的时间点。做到这些之后,模型能力提升会自然变成系统能力提升;否则模型越强,系统越难控制。这也是这套教程从第一章到最后一章反复回到同一张分层图的原因——每一层解决一个确定性问题,合起来就是一个能真正替你改代码、而且你敢让它改的系统。