吃透 AI Agent 开发 · 第 30 篇 · 第六章 · Harness
真正稳定的 Agent 依赖的不只是模型,还包括配置、工具、状态、策略、事件、恢复和运行入口组成的 Harness。
真正稳定的 Agent 依赖的不只是模型,还包括配置、工具、状态、策略、事件、恢复和运行入口组成的 Harness。
同一个模型,为什么表现像两个产品
本地演示里,模型回答得快、工具也能用;换一台机器,配置加载顺序、工作目录、会话恢复和终端事件都变了,结果立刻不稳定。差异通常不在模型本身,而在包裹模型的 Harness:它决定看什么、能做什么、如何暂停、如何记录和如何恢复。
画出控制面和数据面
flowchart TB
CLI[启动与配置] --> Runtime[运行时装配]
Runtime --> Policy[权限与 Hooks]
Runtime --> Session[会话与历史]
Runtime --> Tools[工具注册]
Policy --> Loop[Agent Loop]
Session --> Loop
Tools --> Loop
Loop --> Events[事件流]
Events --> TUI[界面]
Events --> Audit[审计与 Eval]
控制面负责规则、配置、权限和生命周期,数据面负责消息、工具结果和文件内容。把两者混在一起,界面就会直接依赖内部对象,恢复流程也会读取不该暴露的原文。
Harness 的最小合同
interface RuntimeServices {
model: ModelClient
tools: ToolRegistry
sessions: SessionStore
events: EventSink
policy: PolicyEngine
}
interface RuntimeContext {
projectKey: string
sessionId: string
modelName: string
cwd: string
abortSignal: AbortSignal
}
组装器的职责是把依赖明确传入,而不是在深层函数里到处读取全局变量。这样测试可以替换模型、工具和会话存储,运行时也能在恢复时重新选择当前模型,而不是盲目沿用历史配置。
评估 Harness 而不是只评估回答
| 维度 | 验收问题 | 失败证据 |
|---|---|---|
| 启动 | help 是否绕过重依赖 | 启动阶段加载模型 |
| 配置 | 项目覆盖用户配置是否可解释 | 同一字段多处生效 |
| 工具 | 所有入口是否经过策略 | 直接调用 execute |
| 会话 | 恢复是否只恢复上下文 | 历史模型强行覆盖当前模型 |
| 事件 | UI、审计、Eval 是否看到同一事实 | 只能从文本猜状态 |
q-code 的装配顺序
| 阅读步骤 | 路径 | 追踪问题 |
|---|---|---|
| 1. CLI 编排 | src/cli/main.ts |
哪些依赖在入口被决定 |
| 2. 运行环境 | src/context/runtime-context.ts |
本轮上下文从哪里来 |
| 3. 工具管线 | src/tools/registry.ts |
权限、审计和执行如何连起来 |
| 4. 会话存储 | src/session/store.ts |
历史如何恢复而不锁死模型 |
| 5. 终端运行时 | src/terminal/runtime.tsx |
事件如何变成用户界面 |
失败反例:把所有能力交给一个 Agent 类
真正稳定的 Agent 依赖的不只是模型,还包括配置、工具、状态、策略、事件、恢复和运行入口组成的 Harness。若所有能力都塞进一个巨大类,任何小改动都会影响启动、会话、UI 和工具,测试只能靠完整启动才能发现问题。
解决办法不是把类拆成更多名字,而是先定义控制面和数据面的合同。每个服务都通过上下文进入 Loop,事件只传递摘要和句柄,长结果留在数据面文件中。这样才有可能单独测试启动、恢复和工具授权。
做一次 Harness 评审
练习:把项目从命令行参数到最终回答的所有依赖画出来,标记每个依赖的来源、生命周期和替换方式。然后做两次演练:切换模型后继续历史会话;在工具执行中断后重新进入。记录哪些状态被恢复,哪些状态必须重新计算。
Harness 的第一项工作是决定这次根本要不要启动 Agent
用户运行 help、version、audit tail 时,不需要模型、MCP、Ink 和会话恢复。薄 Bootstrap 先识别 early command,能本地完成就直接退出;只有交互、继续会话或自动任务才动态加载主 Runtime。
这条边界看起来离模型很远,却决定产品能否自救。API Key 配错时仍能看帮助,MCP 服务宕机时仍能查审计,TUI 依赖损坏时管道模式还能报告版本。Harness 不是“模型启动后的外围”,它从进程第一行就开始管理故障半径。
启动 trace 记录 route、config、runtime import、tool discovery、session open、terminal ready 各阶段耗时。普通输出不打印敏感配置,debug 时也只给阶段与毫秒。help/version 的模块加载测试断言重型 SDK 从未导入,比某台机器上快几十毫秒更稳定。
配置层先合并来源,再生成 Effective Config
环境变量、用户 config.toml、项目 .q-code/config.toml、命令行参数和进程内 /model 覆盖可能同时存在。深层模块各自读 process.env,就无法解释最终值来自哪里,也难以在测试中隔离。
配置加载器先解析并校验各来源,按固定优先级合并为只读对象:
interface EffectiveConfig {
model: { provider: string; name: string; reasoningEffort?: string }
paths: { qCodeHome: string; sessionDir: string }
safety: { shellAllowAbsCwd: boolean; mentionAllowAbs: boolean }
observability: { audit: boolean; langfuse: boolean; recordIO: boolean }
provenance: Record<string, 'default' | 'user' | 'project' | 'env' | 'cli'>
}
provenance 让 config doctor 能说“model 由 CLI 覆盖,audit 使用默认值”,但 secret 字段永不回显。新增环境变量同步更新示例、TOML section alias 与 README,不让配置能力只在源码里存在。
第一次启动的可选能力默认可禁用。Langfuse、企业 Infra、GitLab KB、MCP 连接缺配置时降级,不应让本地 Agent 无法启动。
Runtime Factory 拥有资源,Loop 只借用服务
Harness 组装模型 client、ToolRegistry、SessionStore、HookRunner、AuditLogger、TerminalRuntime 和 background registries。谁创建资源,谁负责关闭;Loop 接收接口,不在内部创建全局单例。
interface AgentHarness {
config: EffectiveConfig
model: LanguageModel
tools: ToolRegistry
sessions: SessionStore
hooks: HookRunner
audit: AuditLogger
terminal: TerminalRuntime
dispose(): Promise<void>
}
创建顺序同时决定清理顺序。会话先打开、MCP 后连接,退出时先停止新任务与连接,再 flush 会话和审计。装配到一半失败时运行已登记 disposer,不泄漏子进程和 watcher。
测试可以传 FakeModel、内存 SessionStore 和 collecting EventSink,核心 Loop 无需网络。生产则由 Factory 选择真实 adapter。依赖注入的价值不是形式,而是让每条边界可以单独制造失败。
一轮请求先被装进 Run Envelope
用户输入不是一个裸字符串。Runtime 为本轮生成 runId、sessionId、turnId、cwd、当前 mode、AbortSignal、有效模型、工具工作集和 transient context。所有下游事件携带 runId/callId,才能关联。
interface RunEnvelope {
runId: string
sessionId: string
turnId: string
cwd: string
mode: 'normal' | 'planning'
model: LanguageModel
visibleToolNames: string[]
transientMessages: ModelMessage[]
signal: AbortSignal
}
附件、当前 Git 摘要、活动任务、记忆正文和主题人格进入 transientMessages,只发给本轮模型,不写入历史;真实用户输入、assistant message 和必要 tool result 进入 transcript。Envelope 让这个区别在接口上可见。
Session 恢复提供历史与 metadata,Factory 仍用当前 effective model。历史模型只用于提示、usage 和诊断,不能覆盖 Envelope 的 model。
Prompt Pipeline 是 Harness 的信息交换站
稳定核心规则、项目指令和工具纪律组成可缓存前缀;任务、运行环境、记忆、工具 JIT 摘要和团队状态放动态尾部。每个 section 有 stability/category/source/chars,构建时产生 manifest。
Harness 在请求前应用 Context Budget:为输出预留 token,长历史触发压缩,大工具结果只保留 artifact 句柄,项目说明按标题精选,Memory 先选 header 再加载正文。Loop 拿到的是已经通过预算的 messages,而不是自己边跑边删字符串。
Prompt Quality 检查身份、安全、工具、工作流、输出、编辑、记忆、沟通、领域、正反例、失败恢复和品质约束。它防止重构漏掉整类规则,行为是否真的遵守则由工具测试和 Eval 验证。
稳定前缀 hash 进入诊断。某次增加后台任务数量后 cache 突降,manifest 能显示动态团队状态是否误插前缀。
ToolRegistry 是所有副作用的总闸门
内置工具、自定义目录工具和 MCP 工具最终都转换为同一 ToolDefinition,由 Registry 暴露给模型与执行。每次调用经过 schema、路径/参数策略、pre-hook、文件历史快照、真实执行、结果脱敏/offload、post-hook、审计和终端事件。
直接调用底层 execute 会绕过 Harness,底层函数应缩小可见范围。SubAgent、Slash Command 和用户命令也只能构造调用或收窄工具,不获得旁路。
Plan Mode 通过 visible tool set 移除写能力;allowed-tools 只能收窄;项目自定义工具覆盖用户工具时仍受当前 cwd 与 Hook。Capability 来源和权限来源始终分开。
工具结果使用共同信封,长 Shell 输出写 shell-spills,后台命令写 job metadata,SubAgent 长 final 写 agent-artifacts。控制面只拿状态、preview、hash 和恢复路径,数据面原文不进 transcript/audit。
Session、Memory、File History 各保存不同时间尺度
SessionStore 追加用户、assistant、tool metadata、compaction 和 usage,支持 search/export/trash/restore。Memory 只保存用户明确要求长期记住的信息,主题文件有 createdAt/updatedAt/source/status;File History 在内置写工具执行前保存正文快照,供按用户轮次 rewind。
三个系统共享 projectKey/sessionId 等路径 helper,不共享正文。图片只进本轮请求,Shell 全文进 spill,文件 snapshot 进 file-history,长期知识进 memory,避免 Session 变成所有数据的垃圾桶。
写入采用原子 helper。恢复时 transcript 尾行损坏可局部跳过并提示,metadata 不完整可从事件重建,snapshot 正文缺失则 rewind 明确不可用。Harness 不能用一个 try/catch 把不同存储错误压成“会话失败”。
Event Bus 把执行事实交给多个适配器
Loop 发布 text delta、reasoning 状态、tool start/result、usage、context warning、background notification 和 stop reason。Terminal reducer 生成 TUI;classic adapter 输出纯文本;Audit 写 NDJSON;Langfuse observer可选导出 trace;Eval recorder把相同回调归一为测试事件。
事件是稳定语义接口,不是内部变量直播。UI 不解析 audit 文本,Audit 不抓 console.log,Eval 不从最终回答猜工具轨迹。新增界面只订阅,不修改 Loop。
高频 delta 可批量刷新 UI,关键工具事件立即处理;Audit 有界队列满时保留关键事件并计 dropped progress。退出前有时限 flush,不能为了日志永久阻塞进程。
Extension System 应插在明确接缝
Skill 提供工作方法,User Command 展开 Prompt,Output Style改变本轮表达,Hook 观察/干预生命周期,Local Tool 提供本机动作,MCP 提供远程标准能力。Harness 为每类扩展分配加载目录、覆盖顺序、预算和权限。
Skill 和 Command 不直接执行 Shell;Hook modify 后重新校验;MCP 断线只撤下该来源工具;错误扩展不影响 help/version。项目级内容可覆盖用户级同名内容,但内置 Slash 命令优先,避免项目伪造系统命令。
扩展诊断显示 name/source/active/shadowed/error,不打印模板正文和 secret。用户能回答“这项行为从哪里加载”,扩展才是可维护能力,而不是隐藏魔法。
SubAgent 复用 Harness,不复制一套平行系统
子 Agent 使用同一 Loop、工具管线、Hook 与审计,只拥有独立 messages、角色、cwd、allowed-tools、maxTurns 和 taskId。共享稳定 prompt 规则,避免主子安全纪律漂移。
同步子 Agent 返回结果,后台生命周期写 registry/notification;长输出 artifact 化。团队模式再增加 mailbox、worktree 和任务依赖,仍不绕过基础 Harness。
父 run 与 child agentId 进入 AuditContext,工具事件能追到具体执行者。主 Agent 只收到足够做下一步判断的 preview,不把子 Agent 全历史合并回来。
Ready Gate 和 Graceful Shutdown 是同一生命周期两端
启动时核心 config/model/registry 完成才允许提交,可选索引刷新和外部观测后台继续。失败状态必须落定,不能 Promise 永久 pending。
退出时先停止接受输入,abort 当前前台 run,按策略处理后台任务,关闭 MCP/watchers,flush Session/Audit/Langfuse,恢复终端光标。每一步有 deadline,某个外部 exporter 卡住不能阻止退出。
崩溃 guard 不依赖 Ink,用裸 stderr 写短提示,在受控目录生成脱敏 crash report;报告包含版本、阶段、错误摘要和最近事件 ID,不包含 Prompt、文件和工具原文。下次启动提示可恢复 session 与 report 位置。
运行时的测试切片
Harness 不必每次全量 E2E。可以按接缝建立测试:
- Bootstrap fixture 断言 help 不加载 AI SDK/Ink。
- Config fixture 验证用户/项目/环境覆盖与 secret 不回显。
- Prompt manifest 验证稳定 hash 与 transient 不持久化。
- FakeModel 驱动 Loop 工具调用、取消、reasoning 和循环检测。
- Registry 测 schema、Hook、路径、审计与旁路阻断。
- Session 恢复测试当前模型不被历史覆盖。
- Terminal reducer 回放同一事件得到稳定 state。
- CLI subprocess 在隔离 workspace 验证真实文件副作用。
跨模块改动再跑相关 integration 和 deterministic Eval。测试范围跟风险走,不用“全部启动一次没报错”替代接缝验证。
Harness 版本升级要保护落盘协议
代码可以重构,Session JSONL、task graph、Memory frontmatter、file-history metadata 和 Eval artifact 已经存在用户磁盘。每种格式带 schemaVersion,读取旧版显式迁移,无法迁移时报告而不是静默丢弃。
Changelog 告诉用户可见变化,启动时对比上次版本只展示一次。升级不能修改当前模型、重新执行历史工具或自动删除旧 artifact。涉及目录和命令变化时同步 README、内部 docs 与协作说明。
用一条 Capstone Request 走完整 Harness
用户附一张错误截图,说“先分析登录超时,给计划,批准后修复并跑测试”。Bootstrap 已进入 TUI;附件校验后只进本轮;Plan Mode 收窄工具;Prompt Pipeline 注入项目纪律与 auth 候选;Loop 调只读工具;Session 记协议消息;计划 v2 获批后 Registry 开放范围内写工具。
写前 File History 成功快照,工具修改并审计;Shell 测试输出过大写 spill;一个只读 SubAgent并行检查文档,长结果写 artifact;Terminal 从事件展示进度;Eval scorer随后验证 required tools、diff、测试 exit 和预算。
测试失败时任务不完成,模型超时时保留消息账本,用户取消时 Signal 传播;进程崩溃后从 session、task、snapshot 和 artifact 恢复,当前模型仍由新配置决定。Harness 的价值就在于这条链上没有任何一步只能靠“模型应该知道”。
什么时候 Harness 过度设计了
只做无状态文本分类,不需要 Session、Memory、SubAgent 和 file-history。最小 Harness 可能只是 config、model adapter、timeout、metrics 和输入输出。边界按真实任务增加,不能为了复刻代码 Agent 给客服 FAQ 堆一套工作树。
反过来,任务已经写文件、调用外部服务、跨小时恢复,却仍只有一个 Agent.run(),也不是“保持简单”,而是把必要状态隐藏在不可测路径中。
判断标准始终是风险是否真实出现、接缝是否重复、故障是否能解释。Harness 不是框架名字,而是应用明确拥有的运行纪律。