Agent Engineering 101|01|Agent Harness

Agent Harness 不是模型,也不是简单的工具调用封装。它是围绕模型构建的运行时与控制平面,负责上下文、工具、状态、事件、扩展和产品界面之间的边界。

Agent Engineering 101|01|Agent Harness

核心结论

配套代码仓库: github.com/llm-101/mini-agent-harness

读完本文,你应该能回答

  • 为什么模型调用本身不等于 Agent?
  • Harness 为什么是运行时,而不是几段胶水代码?
  • 上下文、工具、状态、事件、扩展和产品外壳分别属于什么边界?
  • 为什么教学实现应该小,但边界必须清楚?

本篇在系列中的位置

  • 上一篇:00 Reading Guide 建立了阅读路线和贯穿例子。
  • 本篇:本文回答整个系列的核心问题:模型之外到底需要什么运行时。
  • 下一篇:02 Layered Architecture 会把 Harness 拆成可实现的分层结构。

贯穿例子

本系列会反复使用同一个任务来连接各章:

用户说:“帮我修复这个 repo 里的 failing tests。”

在本文里,重点不是这个任务最终怎么修好,而是看清楚:当这个任务穿过 Agent Harness 这一层时,Harness 需要承担什么责任,哪些状态要留下,哪些决策不能交给模型随意处理。

Agent Harness 不是一个新的模型接口,也不是“给模型加几个工具”的胶水代码。

它是模型之外的运行时系统:负责把一次 LLM 调用转化为可以持续行动、可以执行工具、可以记录状态、可以恢复上下文、可以被产品界面承载、也可以被观测和治理的 Agent 系统。

如果用一句话概括:

Model 负责生成下一步意图;Harness 负责让这个意图进入真实世界。

mini-agent-harness 这个教学实现里,我们刻意把核心做小,但每一层都对应真实 Agent 产品里的工程问题:

  • 模型只生成文本和工具调用意图;Harness 负责解释这些意图并推动下一步执行。
  • 工具不是直接暴露给模型的函数集合,而是经过 schema、runtime、context 和结果回灌管理的外部能力。
  • 会话不是聊天记录数组,而是可持久化、可分支、可压缩的运行时状态。
  • CLI、TUI、SDK 或 Web UI 都属于 Product Shell;它们不应该污染 Agent Loop 的核心边界。
  • 高级能力不应该一开始写死在 core 里,而应该优先作为 hook、extension、skill、prompt template 等 primitive 出现。
Agent Harness boundary

为什么现在大家都在谈 Harness

过去讨论 AI 应用时,很多注意力集中在模型能力:上下文窗口更大、推理更强、代码能力更好、函数调用更稳定。

但进入 Agent 阶段之后,一个现实问题变得越来越明显:模型本身不等于 Agent。

模型可以输出:

我需要读取 README.md,然后运行测试,再根据失败信息修改代码。

也可以输出结构化工具调用:

{
  "tool": "read",
  "arguments": { "path": "README.md" }
}

但模型并不会真的读取文件、执行命令、保存会话、检查权限、重试失败、压缩上下文、渲染进度条,也不会在进程重启后自动恢复任务。

这些工作都发生在模型之外。

这就是 Harness 的位置。

Martin Fowler 网站上的文章《Harness engineering for coding agent users》把这个概念压缩成一个公式:

Agent = Model + Harness

OpenAI 在 Codex / Agents SDK 相关文档中把 harness 称为模型周围的控制平面:它负责 agent loop、model calls、tool routing、handoffs、approvals、tracing、recovery 和 run state。

Anthropic 在多篇 Claude Agent SDK 和 long-running agents 文章里也把 harness / scaffold 视为“让模型能够作为智能体行动的系统”:它处理输入,编排工具调用,并返回结果。

Cursor 的几篇文章则更偏产品工程视角:模型需要 harness 才能真正有用,而 harness 的持续改进包含上下文管理、评估、模型特定提示、工具可靠性、在线 A/B 测试、长任务编排和多 Agent 协作。

这些定义看起来不同,但指向同一个事实:

真正决定 Agent 是否可用的,往往不是单次模型调用,而是模型外面的运行时边界。

一个工作定义

在这个系列里,我会使用下面这个定义:

Agent Harness 是围绕模型调用构建的运行时与控制系统。它让模型输出进入一个可执行、可恢复、可观测、可扩展、可治理的工程环境。

这个定义里有三个关键词。

第一,Harness 位于模型之外。

模型 API 负责根据输入上下文生成输出。输出可能是自然语言,也可能是结构化工具调用。模型并不真正读取文件、运行命令、写入状态、维护会话树或决定产品 UI 如何展示进度。

第二,Harness 是运行时,不只是胶水代码。

胶水代码通常只是在两个 API 之间传递参数。Harness 要维护一组稳定边界:模型边界、工具边界、状态边界、上下文边界、事件边界、扩展边界和产品界面边界。只要其中一个边界不清晰,Agent 就会退化为一个难以调试的长 prompt 加一组临时脚本。

第三,Harness 是控制平面。

它不只是“调用工具”,还要决定什么时候构造上下文、什么时候触发 compaction、工具调用如何校验、执行结果如何回灌、错误如何进入下一轮、事件如何被 UI 消费、扩展如何介入运行时。

仅靠模型调用不够

一个最小 LLM 调用可以写成这样:

response = model(system_prompt, messages, tools)

一个最小 Agent Loop 可以写成这样:

user message
  → build context
  → call model
  → if tool calls: execute tools
  → append tool results
  → call model again
  → stop

看起来只是一个 while loop,但生产系统很快会遇到更多问题:

问题 仅靠模型调用 Harness 需要承担的职责
如何执行外部动作 模型只能生成工具调用意图 注册工具、暴露 schema、校验参数、执行、捕获错误
如何继续多轮任务 模型不知道外部执行后的真实状态 把 assistant message 和 tool result 写回 session
如何恢复长任务 单次请求没有持久状态 写入 session store,支持 continue / branch / compact
如何控制风险 模型本身不拥有权限系统 在工具、路径、命令、策略层设置边界
如何构造上下文 不能无限塞入所有历史 由 context builder 选择当前工作集
如何做产品体验 模型 API 没有 UI 生命周期 由 CLI / TUI / SDK 消费事件流并渲染状态
如何演进能力 所有逻辑写进 loop 会迅速膨胀 用 extension、skill、prompt template 作为扩展点

所以,Agent Engineering 不能只停留在“如何调用更强模型”。

模型越强,Harness 越重要。因为更强的模型会提出更复杂的行动计划,调用更多工具,跨越更长上下文,也更容易触发权限、状态、恢复和观测问题。

mini-agent-harness 的最小系统边界

mini-agent-harness 的目标不是复刻 Cursor、Codex 或 Claude Code 这样的生产级编码 Agent。

它是一个教学实现:把 Agent Harness 的关键边界显性化,让每一层都足够小,足够容易阅读,也足够接近真实系统。

当前代码可以分成七层:

mini-agent-harness layers

1. Product Shell:产品外壳

对应代码:src/cli/

这一层负责用户界面,而不是 Agent 运行时本身。

仓库里同时保留了两种入口:

  • print-mode.ts:适合脚本、测试和一次性调用。
  • interactive-mode.tsx:基于 Ink 的交互式终端界面。

交互式 TUI 可以显示流式文本、工具调用动画、状态栏、slash commands、取消执行和输入历史。

但这些都不应该进入 Agent Loop。

Product Shell 的职责是消费运行时事件,而不是拥有运行时逻辑。

这也是为什么 src/cli/components/App.tsx 消费的是 harness.stream() 产生的事件流,而不是直接调用模型 SDK 或工具函数。

2. Harness API:对外入口

对应代码:src/core/harness.ts

MiniAgentHarness 是外部使用者看到的最小 API。

它负责装配:

  • JsonlSessionStore
  • ToolRegistry
  • AgentLoop
  • ContextBuilder
  • ExtensionRuntime
  • EventBus
  • 默认内置工具
  • 项目上下文文件
  • Skills

核心入口很少:

await harness.prompt("read README and summarize it")

for await (const event of harness.stream("run tests")) {
  // product shell renders events
}

这里最重要的不是 API 数量,而是方向:

Product Shell
  → MiniAgentHarness
    → AgentLoop
      → Model boundary / Tool runtime / Session store

方向一旦反过来,系统会很快变成 UI、状态、模型调用互相耦合的复杂代码。

3. Agent Runtime:执行循环

对应代码:src/core/agent-loop.ts

Agent Loop 是 Harness 的执行核心。

它在 runLoop() 中完成一轮又一轮的运行时推进:

  1. 追加用户消息。
  2. 必要时压缩历史。
  3. 从 session 取当前分支消息。
  4. 触发 beforeContextBuild extension hook。
  5. ContextBuilder 构造模型请求上下文。
  6. 触发 beforeModelCall hook。
  7. 调用模型边界。
  8. 流式产生 message / thinking / usage 事件。
  9. 写入 assistant message。
  10. 解析 tool call。
  11. 触发 beforeToolCall hook。
  12. ToolRuntime 执行工具。
  13. 写入 tool result message。
  14. 如果还有工具结果需要模型继续处理,则进入下一轮。
  15. 没有工具调用时停止。

这个过程可以画成一张图:

Agent Loop flow

代码里最值得注意的是,AgentLoop.stream() 返回的不是最终字符串,而是 AsyncGenerator<AgentEvent>

也就是说,运行时把每一步都变成事件:

yield { type: "turn_start", turn };
yield { type: "message_start" };
yield { type: "message_update", text: event.text };
yield { type: "tool_call_start", toolName: call.name, input: toolInput };
yield { type: "tool_call_end", toolName: call.name, result: toolMessage };
yield { type: "turn_end", totalMessages };

这就是 Product Shell 能够显示流式输出、工具 loader、thinking 状态、usage 和状态栏的原因:UI 消费的是运行时事件,而不是侵入运行时。

4. Model Boundary:模型边界

对应代码:src/ai/

一个常见错误是让 Agent Loop 直接依赖某个模型 SDK。

短期看这样最快,长期看会把 provider 的消息格式、工具格式、流式协议、usage 字段、错误语义全部泄漏进运行时。

mini-agent-harness 把模型调用隔离成内部接口:

export interface ModelClient {
  complete(request: ModelRequest): Promise<ModelResponse>;
}

再由 provider adapter 处理外部差异:

  • EchoModelClient:无 API key 的教学和测试 fallback。
  • OpenAICompatibleProvider:处理 OpenAI-compatible chat completions、tool calls 和 SSE streaming。
  • AnthropicCompatibleProvider:处理 Anthropic-style messages、content blocks、tool use 和 thinking level。
  • LlmClient:统一包装 provider,并向 Agent Loop 提供稳定的 stream / complete 能力。

这样设计的意义是:Agent Loop 只理解内部 contract,不理解某一家 provider 的外部格式。

换模型、换网关、换 streaming 协议时,优先修改 provider adapter,而不是重写 Agent Loop。

这也呼应了 Cursor 在 harness 文章里强调的一点:不同模型需要不同的工具格式和提示适配,但这些差异应该被 harness 吸收,而不是泄漏给产品上层。

5. Tool Boundary:工具边界

对应代码:src/tools/

工具层分为两部分:

  • ToolRegistry:登记工具,并把工具 schema 暴露给模型上下文。
  • ToolRuntime:根据模型输出执行工具,校验 required 参数,捕获错误,并返回统一 tool result。

当前实现很小,但边界很清楚:

export class ToolRegistry {
  register(tool: Tool): void
  get(name: string): Tool | undefined
  schemas(): ToolSchema[]
}

export class ToolRuntime {
  async executeAll(calls): Promise<ToolResult[]>
  async execute(name, input): Promise<ToolResult>
}

工具执行支持两种模式:

export type ToolExecutionMode = "sequential" | "parallel";

如果是 parallelexecuteAll() 会用 Promise.all() 并行执行多个独立工具调用,同时保持返回结果顺序和输入一致。

这虽然简单,但已经覆盖了真实 Agent 工具层的几个核心问题:

  • 模型看到的是 schema,不是真实函数实现。
  • runtime 负责校验 required 参数。
  • 未知工具会变成结构化错误,而不是直接崩溃。
  • 工具异常会被捕获并转换为 ToolResult
  • 工具结果会被写回 session,成为下一轮模型上下文的一部分。

当前内置工具包括:

  • read / write / edit
  • bash
  • grep / find / ls

它们不是为了覆盖所有能力,而是为了展示一个最小可运行的 world boundary:文件系统、搜索、编辑和命令执行足以解释大多数 Agent Runtime 问题。

6. State + Context:状态与上下文边界

对应代码:

  • src/session/
  • src/context/
  • src/compaction/

在简单聊天产品里,session 可以只是一个 messages 数组。

Agent 不一样。

Agent 的状态至少要回答几个问题:

  • 当前分支是哪一条?
  • 每条消息的父节点是谁?
  • 工具结果是否已经写回?
  • 长上下文压缩后,旧消息如何被 summary 节点替代?
  • 中断后能否从最后一个非 assistant 消息继续?

mini-agent-harness 使用 JSONL session store。每个 entry 都有 idparentIdtimestamptype

这不是为了复杂化聊天记录,而是为了让 Agent 状态具备三个性质:

  1. 可持久化:进程退出后仍能恢复。
  2. 可分支:未来可以从任意 entry fork。
  3. 可压缩:长上下文可以被 summary entry 截断并保留语义连续性。

Context Builder 的职责则不是“把所有东西塞进 prompt”。

当前实现很小:

const messages = [...(input.dynamicMessages ?? []), ...input.messages];
return {
  systemPrompt: input.systemPrompt,
  messages,
  tools: input.tools,
  metadata: {
    messageCount: messages.length,
    toolCount: input.tools.length,
  },
};

但这个位置非常关键。

因为生产级 Agent 的许多能力最终都会进入上下文边界:

  • 系统指令和项目规则。
  • 当前会话分支。
  • 工具 schema。
  • skills 或 prompt templates。
  • 检索结果、动态消息、环境状态。
  • 压缩后的 summary。

如果没有独立的 context builder,这些信息会散落在模型调用前的临时代码里,最后变成不可维护的 prompt 拼接。

7. Extensions + Skills:扩展边界

对应代码:

  • src/extensions/
  • src/skills/
  • src/prompts/

mini-agent-harness 没有把高级能力直接写死在 core 里,而是保留了一组 hook:

beforeContextBuild
beforeModelCall
afterModelCall
beforeToolCall
afterToolCall

这些 hook 可以做很多事:

  • 在 model call 前注入动态上下文。
  • 在 tool call 前做权限检查或路径保护。
  • 在 tool result 后记录 telemetry。
  • 在 context build 前裁剪或重排消息。
  • 注册额外工具。

Skills 则是另一类扩展:它们不是运行时代码,而是任务知识包。

MiniAgentHarness.load() 会从 skillsDir 读取 SKILL.md,然后格式化成 <skill> blocks 追加到 system prompt。

这体现了一个重要设计原则:

高级能力应该优先作为 primitive 或 extension 出现,而不是一开始就变成封闭 feature。

对于教学实现来说,这让代码保持小。

对于生产实现来说,这让系统有演进空间。

Product Shell 不是 Runtime

仓库最近加入了 Ink interactive mode。它让 mini-agent-harness 可以像一个真实终端 Agent 一样交互:输入消息、显示流式输出、显示工具调用、支持 /help/clear/exit,并处理运行中的取消。

但它仍然属于 Product Shell。

核心在于:Ink 只存在于 src/cli/。它消费 harness.stream() 的事件流,不反向侵入 src/core/src/ai/src/tools/src/session/

这条边界很重要。

一个 Agent 产品可以有很多外壳:

  • Terminal UI
  • 一次性 CLI
  • IDE 插件
  • Web UI
  • SDK
  • 自动化任务入口

如果运行时设计正确,这些外壳可以共享同一个 Agent Harness。

如果运行时设计错误,每增加一个外壳,就会复制一套模型调用、工具执行、状态管理和错误处理逻辑。

和 Cursor、OpenAI、Anthropic 的定义对齐

现在可以回头看几家公司的定义。

Cursor 文章强调持续改进 harness:上下文窗口怎么管理、工具错误怎么分类、不同模型如何定制提示和工具格式、如何用 CursorBench 与在线实验评估质量。

OpenAI 的 Codex 文章强调 agent loop、tool routing、tracing、recovery 和 app server 这类控制面能力。

Anthropic 的 long-running agents 文章强调跨多个 context window 工作时,单靠 compaction 不够,还需要 initializer agent、coding agent、progress file、git history 等环境管理机制。

Martin Fowler 的文章则把用户侧 harness 也纳入视野:guides 作为前馈控制,sensors 作为反馈控制,通过测试、linters、review agents、规则文档等方式提高 Agent 首次做对和自我纠正的概率。

这些看法都可以映射到 mini-agent-harness

外部概念 mini-agent-harness 中的对应模块
Agent loop src/core/agent-loop.ts
Model boundary src/ai/
Tool routing / tool runtime src/tools/
Run state / recovery src/session/
Context management src/context/ + src/compaction/
Skills / task knowledge src/skills/
Policy / hooks src/extensions/
Product shell src/cli/
Tracing / usage / events src/telemetry/ + AgentEvent

教学实现并不追求复杂,但它把真实系统的边界缩小到了可以阅读的尺度。

这篇文章的代码锚点

如果你想从代码里理解这一篇,建议按这个顺序读:

  1. src/core/harness.ts
    • MiniAgentHarness 如何装配 session、tools、loop、extensions、event bus、skills 和 project context。
  2. src/core/agent-loop.ts
    • stream()runLoop() 如何把 user message、context build、model call、tool call、tool result 和 next turn 串起来。
  3. src/tools/tool-runtime.ts
    • 看 tool schema、required 参数校验、parallel / sequential 执行和错误捕获。
  4. src/context/context-builder.ts
    • 看上下文边界为什么应该独立存在,即使当前实现很小。
  5. src/session/jsonl-session-store.ts
    • 看为什么 Agent 会话不只是 messages 数组。
  6. src/cli/interactive-mode.tsxsrc/cli/components/App.tsx
    • 看 Product Shell 如何消费事件流,而不是侵入 Runtime。

小结

Agent Harness 的价值不是让模型变聪明。

它的价值是让模型输出进入一个工程系统:

  • 可以执行;
  • 可以恢复;
  • 可以观测;
  • 可以扩展;
  • 可以治理;
  • 可以被产品界面承载;
  • 可以在长任务和复杂工具调用中保持边界清晰。

mini-agent-harness 的第一篇只讨论总边界。

接下来几篇会逐层拆开:模型调用边界、Agent Loop、消息与事件、Session Store、Tree History、Tool Runtime、Context Builder、Compaction、Skills、Extensions,以及最终的交互式 TUI。

如果把这些层都讲清楚,Agent 就不再是“一个模型加一个 while loop”,而是一套可以被理解、调试和演进的运行时系统。

参考文章