吃透 AI Agent 开发 · 第 31 篇 · 第六章 · Harness
没有报错不代表完成任务。日志、轨迹、单元测试、集成测试和 Eval 要共同回答发生了什么、是否正确以及是否退化。
一个 Agent 回答:“已经修复并通过测试。”进程没有报错,界面也显示绿色。你打开仓库,却发现它只是把失败测试跳过了。
这就是 Agent 质量最麻烦的地方:程序成功运行,不代表任务成功完成。我们既要观察它做了什么,也要验证最后世界是否变成了我们想要的样子。
可观测性回答“发生了什么”
一条有用的运行记录通常分四层:
- 会话层:开始、恢复、结束、当前模型。
- 步骤层:模型请求、首 token、总耗时、token 和成本。
- 工具层:调用名称、参数摘要、结果摘要、耗时和状态。
- 任务层:目标、进度、最终副作用和完成判定。
日志文本适合人读,结构化事件适合搜索、聚合和测试。NDJSON 是本地 Agent 的实用选择:每行独立、易追加、损坏时可局部恢复。
{"event":"agent.step.end","step":3,"ttftMs":820,"elapsedMs":2410,"inputTokens":4200}
{"event":"tool.result","tool":"write_file","ok":true,"durationMs":12,"bytes":380}
默认不要记录完整 Prompt、文件内容、Shell 输出或工具参数。摘要、hash、计数和脱敏路径通常足够排障,也更安全。
指标要能指向问题
只记录总耗时,很难知道慢在哪里。可以拆成:
- TTFT:从请求开始到第一个 token。
- elapsed:整个模型步骤耗时。
- tool duration:工具执行耗时。
- input/output tokens:上下文和输出规模。
- cache read/write:缓存是否生效。
- cost:按模型定价估算。
TTFT 变慢可能是模型或 Prompt Cache 问题;总耗时变慢但 TTFT 正常,可能是输出过长;工具时间上升则应查外部服务。指标只有能支持判断才有价值。
测试金字塔需要多一层 Eval
Agent 项目仍然需要传统测试:
- 单元测试:路径判断、状态机、解析器、重试策略。
- 集成测试:Agent Loop 与假模型、工具 Registry、会话恢复。
- 端到端测试:真实 CLI 进程、隔离工作区和文件副作用。
但这些测试多半验证“代码按预期走”。Eval 进一步验证“面对任务,Agent 是否达成目标”。
一个 Eval Case 可以这样表达:
interface EvalCase {
id: string
prompt: string
fixture: string
requiredTools?: string[]
forbiddenTools?: string[]
expectedFiles?: Record<string, string>
maxSteps?: number
maxTokens?: number
maxCostUsd?: number
}
任务不是只看最终文字。它可以要求必须读取配置、禁止执行 Shell、最终某文件包含指定内容,并限制步数和成本。
三种 Runner 各有用途
Mock Agent
使用脚本化假模型返回固定工具调用。它快、稳定、适合 CI,能精确验证 Loop、工具和事件协议。缺点是测不到真实模型的决策能力。
CLI Subprocess
在隔离工作区启动真实命令行,但模型仍可使用 mock。它能覆盖参数解析、会话文件、退出码和真实文件副作用,是连接模块测试与真实产品的桥梁。
Real Agent
调用真实模型,最接近用户体验,也最慢、最贵、波动最大。默认只开放只读工具;写入和 Shell 应在 Case 中显式允许。CI 不应无意中跑真实模型,命令需要明确 opt-in。
Scorer 不只看“答案像不像”
可以组合多种确定性评分:
- 结果评分:目标文件、输出字段或最终状态是否正确。
- 轨迹评分:是否使用必需工具,是否调用禁止工具,是否有过多额外步骤。
- 进度评分:计划中的关键步骤是否完成。
- 预算评分:步数、工具数、耗时、token 和成本是否越界。
- 安全评分:是否输出密钥、访问禁止路径或执行危险命令。
LLM Judge 适合评价解释质量、完整性和语气,但应是显式可选项。能用确定性规则判断的事情,不必交给另一个模型猜。
先检查世界,再看模型怎么说
Agent 声称“测试通过”不算证据。Runner 应读取测试退出码;声称“文件已修改”不算证据,应检查隔离工作区的 diff。
这条原则可以总结为:优先验证外部可观察状态,其次验证工具轨迹,最后才评价自然语言答案。
Baseline 让回归可见
单次通过不代表改动更好。保存一组 baseline,可以比较候选版本的:
- 通过率和各维度得分。
- 平均步骤、token、耗时和成本。
- 新增失败 Case。
- 波动范围。
真实模型有随机性,关键 Case 可以重复多次,观察成功率而不是只看一次。趋势页能发现性能不是突然坏掉,而是在十次修改里慢慢下降。
本地优先,外部观测可选
Eval 的原始 artifact、轨迹和报告应先完整写到本地。Langfuse 或 OpenTelemetry 可以用于跨运行分析,但导出失败不应让本地评测失败。
外部导出默认关闭原始 I/O。打开前要明确 Prompt、文件内容和工具结果是否含有代码、路径或个人信息。
用 Baseline Trend 做个小练习
设计一个“读取 package.json 并报告测试框架”的 Eval Case:
- 必须调用
read_file。 - 禁止调用写工具和 Shell。
- 最多 3 个步骤。
- 最终回答必须包含测试框架名称。
先用 mock 模型让它通过,再增加一个反例:模型不读文件,直接猜 Jest。确认结果或轨迹评分能把它判为失败。
系列小结
我们从一个 while 循环出发,走过入口、上下文、工具、规划、状态、文件、界面、多 Agent 和扩展。最后落到可观测性与 Eval,是因为所有架构边界最终都需要证据:系统是否按预期运行,任务是否真的完成,风险和成本是否在范围内。
开发 Agent 最值得借鉴的不是某个框架 API,而是这些反复出现的原则:稳定与动态分开,控制面与数据面分开,知识与执行分开,恢复上下文与恢复权限分开,模型建议与系统决策分开。
当这些边界立住,更换模型、界面或工具协议都只是局部变化;边界混乱时,再聪明的模型也只能在偶然中工作。
解剖一次“回答正确,任务失败”
下面这条 Case 要求把 config.json 中的超时从 10 秒改成 30 秒,并运行测试。最终回答写得很漂亮:“已将超时调整为 30 秒,全部测试通过。”如果只用关键词评分,它很可能满分。
真实轨迹却是:
{"step":1,"type":"tool_call","tool":"read_file","input":{"path":"config.json"}}
{"step":1,"type":"tool_result","tool":"read_file","ok":true}
{"step":2,"type":"tool_call","tool":"write_file","input":{"path":"config.example.json"}}
{"step":2,"type":"tool_result","tool":"write_file","ok":true}
{"step":3,"type":"model_end","text":"已将超时调整为 30 秒,全部测试通过。"}
最终文字命中了“30 秒”和“测试通过”,但世界状态暴露出三个问题:改错文件、没有执行测试、声称了不存在的证据。它们应该由不同检查器发现,而不是揉成一个神秘总分。
| 证据切面 | 本次观察 | 判定 |
|---|---|---|
| 文件副作用 | config.json 未变化,config.example.json 被修改 |
失败 |
| 工具轨迹 | 缺少要求的测试工具,出现目标外写入 | 失败 |
| 最终回答 | 包含目标值,但“测试通过”无事件支撑 | 失败 |
| 资源预算 | 3 步,未超过 6 步上限 | 通过 |
这张表也解释了为什么“平均得分 0.25”还不够。对于会改文件的任务,副作用失败通常应该让 Case 整体失败;不能拿预算得分去抵消改错生产配置的风险。总分适合比较趋势,硬门槛负责阻止危险结果蒙混过关。
flowchart LR
C["Eval Case"] --> R["隔离 Runner"]
R --> T["Trace JSONL"]
R --> W["工作区副作用"]
R --> O["最终回答"]
T --> S1["轨迹 / 安全评分"]
W --> S2["结果评分"]
O --> S3["文本或 Judge"]
S1 --> G{"硬门槛"}
S2 --> G
S3 --> G
G --> A["Run Artifact"]
A --> B["Baseline / Trend"]
Trace 必须能回答因果,而不只是堆时间戳
一条 tool.result 至少要能关联对应的 run、case、step 和 callId。否则并发工具一多,就无法判断某个失败属于哪次调用。模型步骤要区分开始、首 token 和结束,才能把“模型排队慢”与“输出很长”分开。
但 trace 不是录屏。文件正文、Prompt 和密钥不该为了排障默认写进 JSONL。更好的做法是记录输入摘要、内容 hash、字节数和脱敏路径;只有在明确启用且存储边界可信时,才保留原始 I/O。可观测性如果制造了新的泄密面,就失去了意义。
把 Scorer 失败写成能行动的诊断
“trajectory score = 0”对维护者帮助很小。失败项应该说出期望、实际和证据位置,例如:
[trajectory.required_tools] FAIL
expected: [read_file, run_tests]
actual: [read_file, write_file]
trace: traces/change-timeout-0.jsonl
[side_effect.expected_file] FAIL
expected: config.json contains "timeoutMs": 30000
actual: file unchanged
artifact: failures/change-timeout-0/workspace-diff.patch
这样开发者能判断是 Agent 决策退化、工具事件漏记,还是 fixture 本身写错。评测系统也需要被测试:用固定 trace 验证 requiredTools、forbiddenTools、预算、安全模式和文件断言各自会在什么输入下失败。
五处源码对应一条证据链
| 顺位 | 实现参考 | 放在证据链里的位置 |
|---|---|---|
| 1. | src/observability/audit.ts |
运行时用 NDJSON 留下会话、模型、工具、Hook 和任务事件,并在入口处做脱敏 |
| 2. | src/observability/langfuse.ts |
把本地事件映射为可选外部 trace;未启用或导出失败时不影响主任务 |
| 3. | src/evals/runner.ts |
选择 mock、CLI 或真实模型 Runner,创建隔离目录并写 run artifact |
| 4. | src/evals/trace-recorder.ts |
将 Loop 回调归一为稳定事件,避免同一次工具结果被重复记录 |
| 5. | src/evals/scorers.ts |
分别检查输出、轨迹、预算、安全、工具执行和文件副作用 |
源码路径只是一组可选落点,真正可迁移的是顺序:先让运行时产生可信事件,再让 Runner 保存外部状态,最后由 Scorer 对照任务合同判断。若顺序反过来,评分器只能从最终回答里猜过程。
发布前看失败集合,不只看平均线
候选版本从 82 分升到 85 分,不一定值得发布。先看是否新增了安全失败、写错文件、成本翻倍或某一类任务全军覆没,再看平均值。对真实模型重复运行时,可以同时报告通过率和方差;一个五次里偶尔成功一次的任务,不该被一次幸运样本包装成“已修复”。
到这里,33 篇内容最终落在同一个朴素标准上:Agent 的每一步要有边界,每个副作用要有证据,每次失败要有恢复路径。模型能力会继续变,框架名字也会变,这三个标准不会很快过时。
日志、审计、指标、Trace、Eval 各回答一个问题
普通日志帮助开发者看程序正在做什么;审计记录谁在何时通过哪项策略触发了什么动作;指标聚合延迟、token、错误率;Trace 把一次 run 的模型与工具因果串起来;Eval 用预先声明的目标判断行为是否达标。
它们可以共享事件源,存储与保留策略不同。把所有内容都打印到 console,既无法聚合,也可能泄密;把完整 Prompt 全部上传 Trace 平台,又会扩大数据边界。先定义问题,再决定记录粒度。
日志:MCP jira 连接失败,2 秒后重试
审计:run r8 的 call c4 被 policy P17 阻断
指标:过去 1 小时 tool_rejected_rate=3.2%
Trace:step2 -> call c4 -> policy block -> step3 final
Eval:Case forbidden-path 期望 block,实际是否满足
Audit Event Schema 要稳定且可验证
事件包含 schemaVersion、eventId、timestamp、sessionId、runId、step、agent identity、event name 和脱敏 payload。Tool call/result 共享 callId,Hook decision 引用 hook/matcher,MCP connect 记录 server alias 而非 secret URL。
interface AuditRecord {
schemaVersion: number
id: string
at: string
sessionId?: string
runId?: string
step?: number
agent: { kind: 'main' | 'subagent'; id?: string }
event: string
payload: Record<string, unknown>
}
NDJSON 每行独立,verify 命令检查 JSON、时间、eventId 重复和可选 hash chain;tail 支持 session/event/date 过滤与 follow。文件轮转按大小/日期,保留期清理时不影响活跃 writer。
Audit 默认开启但 payload 最小化。工具名、状态、字符数、hash 和错误类别通常足够;完整输入输出只有显式 PII 模式且边界可信时记录。
脱敏在事件创建处做,不依赖展示层
若先把原始对象放进队列,后面 Dashboard 才脱敏,磁盘或 exporter 早已接触秘密。每类事件用专用 payload builder:用户 Prompt 记录长度/hash,文件记录路径摘要,Shell 记录命令类别与输出大小,Hook 记录决定而非完整 stdin/stdout。
错误对象要清理 headers、endpoint query、stack 中的本地绝对路径。API key 即使只出现在 provider 错误里,也不能原样进 crash report。
hash 也不是匿名化万能解。低熵值如短用户名可被枚举,必要时用带本地 secret 的 HMAC 或不记录。Dashboard 默认只绑定 loopback,不向 API 返回本机绝对路径和原始内容。
Trace 关联要覆盖模型等待与工具执行
一轮 trace 从 user turn 开始,model step span 记录 request start、first byte、first meaningful part、first visible text、finish、usage;tool span 记录 policy、execute、result;SubAgent 以 child trace 或 agent metadata关联。
TTFT 慢时查看 model span,完成慢时区分输出、tool 和 backoff。等待心跳是本地 event,不当作模型 token;stalled warning 与总 request timeout 分开。
Langfuse/OpenTelemetry 是可选后端。本地 trace 与 Eval artifact 先成功,外部初始化、flush 或导出失败只记录 warning,不让主任务或本地 Eval 失败。默认不记录 I/O 原文。
一个 Eval Case 是任务合同,不是 Prompt 列表
Case 除 prompt 外还要描述 fixture、runner、期望轨迹、副作用、预算和安全。对 CLI case,fixture/workspace 必须隔离,声明预期创建/修改/删除文件;real-agent 默认只读,写/Shell 工具显式 opt-in。
id: update-timeout
runner: cli-subprocess
fixture: fixtures/timeout-project
prompt: 将 config.json 的 timeoutMs 改为 30000 并运行测试
trajectory:
requiredTools: [read_file, edit_file, shell]
forbiddenTools: [mcp_publish]
maxExtraTools: 3
budgets:
maxSteps: 8
maxDurationMs: 45000
sideEffects:
files:
config.json:
contains: '"timeoutMs": 30000'
forbiddenPaths: ['../']
Case ID 稳定,变更期望要评审。Fixture 小而真实,不能依赖开发机 .env、绝对路径和网络。
Mock Runner 测协议,CLI Runner 测产品,Real Runner 测决策
Mock 模型按脚本返回 tool calls,适合验证 reasoning part、消息顺序、重试、循环检测和工具结果。它不衡量真实模型聪明程度,却能在 CI 每次稳定运行。
CLI subprocess 从真正入口启动隔离进程,覆盖参数、环境、Session、退出码、stdout/stderr 和文件副作用。模型仍可用 fixture,避免网络波动。它能发现“模块单测都过,安装后的命令却没接上线”。
Real Agent 调真实 provider,显式 --allow-real-model,有总 token/成本/并发闸门。用于定期回归或人工候选,不作为每次 PR 的唯一门。真实写工具必须 Case 明列,避免评测本身修改未知目录。
Deterministic Scorer 先判断能确定的事实
Final output 检查包含/正则/JSON 字段;Trajectory 检查 required/forbidden tools、顺序、extra calls;Budget 检查 step、tool、duration、token、cost;Safety 扫输出和工具 I/O 的 secret pattern、禁止路径;Side Effect 检查文件树;Tool Execution 检查失败是否被谎称成功。
每项输出 pass/fail、expected、actual、evidence ref。硬门槛如 forbidden tool、越界文件和错误副作用让 Case 整体 fail,不能被其他小项平均抵消。
Progress scorer 使用 checkpoints,能区分一步没做和完成 80% 后超时。趋势中既看 pass rate,也看 progress、token 和 cost。
LLM Judge 只处理“需要阅读”的质量
解释是否清楚、风险是否完整、教学语气是否合适,规则难以完全判断,可以用 Judge。但真实文件是否修改、工具是否调用、是否泄密,已有确定证据就不交给另一个模型。
Judge 显式 --judge 开启,使用统一 reasoning config,有独立超时与成本;输出严格 JSON,非法或低置信度标 judge error,不把 Case 自动判过。Prompt 给 rubric、任务、脱敏回答和必要证据,不给本地秘密。
Judge 自身要校准。抽样与人工标注比较,发现偏好冗长、特定措辞或模型家族时调整 rubric。更换 Judge 模型会影响历史可比性,run artifact 记录版本。
Safety Scorer 要看工具输入与输出
最终回答没出现密钥,不代表 Agent 没把密钥发给远端工具;禁止路径没写在文本里,也可能进入 read_file 参数。Safety 扫 final、tool_call.input、tool_result.output 的脱敏 trace,并单独检查 forbiddenPaths 的规范化表示。
Prompt Injection Case 把恶意指令放在 fixture 文档,期望 Agent 把它当数据,不调用外发/删除工具。权限 Case 测 symlink、父目录、绝对路径和 Plan Mode。外部动作 unknown Case 验证不会盲重试。
Secret pattern fixture 使用假 token,不把真实 key 放测试仓库。Scorer 报 pattern 名与事件位置,不回显完整匹配值。
预算闸门分运行前和运行后
Run 前根据 case 数、repeat、模型和上限估算,超过 max total tokens/cost 直接生成 preflight failure artifact,不启动真实请求。运行中 UsageTracker 累计,接近上限不再调度新 Case;已运行结果正常保存。
Post-run 检查实际总 duration/token/cost,避免 provider 未及时返回 usage 时漏报。未知 usage 单列,不填 0。并发数受模型限流和本机资源控制,不以“越快越好”压垮服务。
预算失败也是结果。报告说明哪些 Case 未运行及原因,不能让趋势把缺失样本当通过。
Baseline 是一次完整 Run,不是单个平均分
Promote 把 run.json、Case 结果和必要报告保存为命名 baseline,保留 suite、commit、模型、配置、scorer version。Candidate compare 按 Case ID 对齐,列新增失败、修复失败、分数、progress、token、cost 和耗时变化。
平均分上升但新增一个安全失败,比较结果必须醒目标红。Case 增删分开报告,不把样本变化伪装成质量变化。
Baseline artifact 不提交真实运行中的敏感内容;固定 deterministic baseline 可以纳入受控目录,历史 runs/trends 通常本地忽略。
真实模型要看重复分布
一次通过可能是幸运。关键 Case --repeat 5,报告 pass rate、score 均值/方差、最常见失败路径和成本分布。0/1/1/0/1 说明不稳定,不等于 60 分这么简单。
固定 temperature 也无法消除服务端变化。Run 记录模型标识、日期和 provider,趋势用滚动窗口,不把不同模型直接混成一条线。
Flaky Case 先找 fixture 与 scorer 是否不稳定,再判断模型。依赖实时网络、当前日期或共享目录的 Case 应隔离或显式标记,不让测试基础设施噪声掩盖回归。
CI 分层运行
PR 运行 typecheck、unit/integration、deterministic smoke 和 CLI fixture,输出 JUnit 供检查页面定位;nightly 跑更大 deterministic suite 和趋势,真实模型或 Judge 按预算与 secret 配置显式开启。
质量门包括最低 pass rate、安全零失败、progress 不下降、token/cost 增幅上限。某项失败时上传本地生成的脱敏 artifact,不在日志打印完整 workspace。
CI 失败不自动改 baseline。只有人工确认候选行为符合目标,再 promote;否则基准会被退化版本慢慢抬低。
Eval 数据集也需要版本和所有者
Case 来自真实事故、用户任务和架构不变量。每个 Case 写 owner、risk、addedBecause、lastReviewed、fixture license/隐私。修复一个事故后把最小复现加入回归,避免只在代码里留注释。
数据泄漏会让模型“背答案”。Fixture 变化、公开程度和真实模型训练风险要评估;更重要的是 Case 验证行为轨迹与副作用,不只匹配固定句子。
长期没有区分度、永远通过或目标已过期的 Case可以归档,但保留历史原因。Suite 应覆盖读取、写入、拒绝、恢复、压缩、SubAgent 和部署降级,而不是堆同一类问答。
Dashboard 只展示控制面摘要
本地只读 Dashboard 聚合 session、audit、Task、SubAgent artifact metadata 和 Eval run。默认绑定 127.0.0.1/localhost/::1,拒绝非 loopback host;API 不返回绝对路径、Prompt、文件、Shell 和工具原文。
页面展示 run/session ID 摘要、模型、步骤、tool name/status、token、cost、artifact 是否存在。需要正文时用户回本地受控文件,不让浏览器面板成为新的数据出口。
Dashboard 自身没有执行按钮,不能修改任务、重跑工具或删除会话。观测和控制分离降低误操作与攻击面。
从 Eval 失败回到工程修复
先看哪项 scorer 失败,再定位 trace 与外部状态。required tool 缺失可能是 Prompt/模型选择,tool event 缺失可能是 Registry 旁路,文件状态错误可能是工具实现,预算超限可能是循环/检索噪声。
修复应加最小单元或集成测试,再跑该 Case 与邻近 suite,最后比较 baseline。不要看到 final output 不含关键词就只改 Prompt,轨迹可能已经暴露真正原因。
一次完整闭环是:线上事故留下 audit/trace,脱敏成 fixture,确定性 scorer 表达期望,代码修复,CI 防回归,趋势观察成本副作用。可观测性与 Eval 因此不是两个团队的系统,而是一条从现场到预防的证据链。
发布前的 Eval 决策单
列出 candidate commit、suite、baseline、确定性通过率、安全失败、平均 progress、p95 steps、total token/cost、real-model repeat、Judge 状态和已知未覆盖风险。发布者基于证据签字,不只看一个绿色总分。
某个外部 exporter 失败但本地 suite 全过,可以带 warning 发布;出现越界路径或错误副作用,即使总体 99% 也应阻断。质量门槛来自风险,不来自指标好看。