模型不是 Agent:从零实现一个最小 Agent Loop

100 阅读14分钟

调用一次大模型 API,可以得到一段文本,也可能得到一个工具调用请求。但这还不是一个完整的 Agent。

原因并不复杂:模型只能生成“下一步应该做什么”,不能替宿主执行函数,也不知道函数执行后发生了什么。真正让任务持续向前推进的是模型外部的执行系统。它负责组织上下文、调用模型、执行工具、回传结果,并在任务完成、失败或取消时结束运行。

从一个最小 Agent Loop 出发,可以先提出一个判断:

模型提供生成能力,Agent Loop 提供执行语义。只有两者形成闭环,模型调用才成为可持续推进的 Agent 执行。

这里的“执行语义”具体包括:一次任务如何开始,工具结果怎样回到模型,上一步与下一步怎样关联,什么情况可以恢复,什么情况必须失败,以及取消后如何保证不再启动新工作。

一、从模型调用到 Agent 执行

普通模型调用只有一个从 Model Request 到 Model Response 的往返。如果 Response 是最终文本,这个往返已经结束;如果 Response 是 Tool Call,程序还欠模型一次工具执行结果。完整过程因此是:

用户输入
  → 组装模型请求
  → 模型返回 Tool Call
  → 宿主执行工具
  → Tool Result 回到模型上下文
  → 模型继续调用工具或给出最终回答

模型不会因为生成了 calculator({ expression: "6 * 7" }) 就自动得到 42。工具是否存在、参数是否合法、执行是否成功,都只能由宿主确认。

Agent Loop 的职责就是管理这些尚未完成的工作。当模型提出工具调用时,Loop 不能结束;当工具结果已经产生时,Loop 不能把结果留在模型看不到的局部变量里;当模型不再请求工具时,Loop 才能正常完成。

Agent Harness:Agent 的运行框架。它通常负责模型接入、工具、会话、权限、取消、事件和资源管理。Agent Loop 是 Harness 中推进任务的控制部分,不等于整个 Harness。

要把这段执行过程说清楚,还需要区分 Turn、Step、Message 和 Tool Call。它们不是同一件事的不同叫法,而是分别描述数据、请求和执行范围。

概念定义生命周期
Message一次模型请求中的对话记录随模型上下文传递
Tool CallAssistant Message 中的结构化工具请求从模型产生,到对应 Tool Result 生成
Tool Result宿主执行工具后形成的结果生成后作为 Tool Message 回到模型上下文
Step一次模型请求,以及这次响应产生的工具调用从请求模型开始,到该批工具处理完成
Turn围绕一次用户输入展开的完整执行从接收输入开始,到最终回答、失败或取消

模型上下文中使用三种 Message:

  • User Message:用户提供的任务输入。
  • Assistant Message:模型返回的文本以及零到多个 Tool Call。
  • Tool Message:宿主执行后生成的 Tool Result。

一个 Step 只包含一次模型请求,但可以包含多个 Tool Call。一个 Turn 可以包含多个 Step,因为工具结果返回后,通常还需要再次请求模型。

Turn
├── Step 1:模型请求 → Tool Call → Tool Result
├── Step 2:模型请求 → Tool Call → Tool Result
└── Step 3:模型请求 → 最终回答

区分 Step 和 Turn 不是为了增加术语,而是为了明确资源限制和错误范围。例如 maxSteps = 5 表示一次用户任务最多请求模型五次,而不是最多执行五个工具。模型在一个 Step 中同时调用两个工具,仍只消耗一个 Step。

每个 Tool Call 都需要独立编号。它至少包含调用编号 id、工具名 name 和参数 input。Tool Result 使用同一个编号返回。这样即使模型一次调用多个相同工具,程序仍能准确判断每个结果属于哪个请求。

Correlation ID:用于关联请求与结果的标识。ToolCall.idToolResult.callId 组成这条关联关系。

这些对象最终由 Agent Loop 组织成一个有边界的状态机:

image.png

State Machine:状态机用有限状态、转换条件和终止结果描述执行过程。在这里,它要求 completed、failed 和 cancelled 具有不同含义,而不是都表现为“循环退出”。

这个状态机包含三个关键判断。

第一,模型响应没有 Tool Call,表示模型不再要求宿主执行工作,Turn 可以正常完成。

第二,模型响应带有 Tool Call,表示当前 Turn 仍有未完成工作。Loop 执行工具、保存结果,然后进入下一个 Step。

第三,超过资源上限或收到取消时,即使模型还想继续,Loop 也必须终止。模型输出只是请求,不能越过 Harness 的运行约束。

二、模型与工具之间的稳定接口

Agent Loop 不应直接依赖某个模型 API 的字段。它只需要知道“怎样发起一次模型请求”:

interface ModelAdapter {
  complete(request: ModelRequest): Promise<ModelResponse>
}

interface ModelRequest {
  messages: readonly Message[]
  tools: readonly ToolDescription[]
  signal: AbortSignal
}

Adapter:适配器把外部系统的请求和响应转换为程序内部的稳定接口。更换模型服务时,Agent Loop 不需要随 API 字段一起改变。

messages 是本次模型真正可见的上下文,tools 是允许模型调用的工具描述,signal 用于传递取消。

工具侧也需要一个稳定入口。Tool Registry 保存两类信息:

  1. 发给模型的工具名称、用途说明和输入 Schema。
  2. 仅由宿主持有的执行函数。

模型只能看到第一类信息。函数对象、文件句柄或宿主私有状态不能进入模型请求。

Registry:按照稳定名称保存和查找实现的注册表。Agent Loop 通过工具名请求执行,不直接依赖每个具体工具模块。

工具参数使用 JSON Schema 描述。例如计算器声明一个必填的 expression 字符串,read_memory 声明一个必填的 key 字符串。

JSON Schema:描述 JSON 数据结构和约束的标准格式。它能告诉模型应该生成什么参数,但模型输出仍是外部输入,宿主必须在运行时重新校验。

这一点容易被 TypeScript 掩盖。类型注解只约束编译期代码,不能保证 API 返回的 JSON 符合类型。直接把模型参数断言为某个接口,或者用 eval() 执行模型生成的表达式,都没有建立真实的运行时安全条件。

三、核心循环与上下文闭环

去掉事件字段组装和错误包装后,核心循环可以压缩为下面这段代码:

while (true) {
  throwIfAborted(signal)
  if (turn.steps.length >= maxSteps) {
    throw new MaxStepsExceededError(maxSteps)
  }

  const response = await model.complete({
    messages: structuredClone(messages),
    tools: registry.descriptions(),
    signal,
  })

  messages.push({
    role: "assistant",
    content: response.content,
    toolCalls: response.toolCalls,
  })

  for (const call of response.toolCalls) {
    const result = await executeAsToolResult(call, signal)
    messages.push({ role: "tool", ...result })
  }

  if (response.toolCalls.length === 0) {
    return response.content
  }
}

这段代码表达了执行主干,但可靠性不来自 while (true) 本身,而来自它周围的约束:

  • 每轮开始前检查取消和最大 Step。
  • 每次模型响应都先写入上下文。
  • 每个 Tool Call 都形成对应 Tool Result。
  • 只有没有 Tool Call 时才正常结束。
  • Step 和 Turn 的开始、完成、失败都产生结构化事件。

如果缺少其中任何一项,循环仍然可以运行,但它的状态可能无法解释。例如工具执行成功却没有回传模型,程序表面上继续运行,模型实际上仍停留在调用工具之前的上下文。

这段循环中最关键的动作,是把 Tool Result 重新放回模型上下文。可以用一次计算器调用具体观察这个过程。

假设模型用 call-1 请求 calculator({ expression: "6 * 7" }),宿主执行后得到 42。下一次模型请求需要追加一条 Tool Message:

{
  "role": "tool",
  "callId": "call-1",
  "content": "{\"result\":42}",
  "isError": false
}

这里有两个不能省略的信息。

一是结果内容。模型只提出了计算请求,并不知道宿主实际返回什么。二是 callId。它把结果与之前的请求关联起来,避免并行或重复工具调用时发生错配。

工具失败也要回传。模型需要知道“工具执行失败”,而不是看到上下文突然中断。错误 Tool Result 可以让模型解释失败、修改参数,或者选择其他工具。

Function Calling / Tool Calls:模型生成函数名称和结构化参数的协议能力。它不负责执行函数,也不会自动获得宿主权限。

四、失败、资源上限与取消

Agent Loop 中的错误来源不同,处理方式也不同。

情况是否继续 Turn处理方式
未知工具可以转成错误 Tool Result,交给模型处理
工具参数或执行失败可以保留 callId,回传结构化错误
模型请求失败通常不可以当前 Step 和 Turn 标记为 failed
模型响应协议损坏不可以Adapter 报错,避免使用不完整上下文
超过最大 Step不可以抛出 MaxStepsExceededError
调用方取消不可以当前 Step 和 Turn 标记为 cancelled

这一区分的依据不是异常类型是否严重,而是 Loop 是否仍拥有继续执行所需的可靠信息。

工具失败时,Tool Call 已经存在,只缺少正常结果,因此可以把失败作为结果交给模型。模型响应无法解析时,连 Assistant Message 是否完整都无法确认,继续执行会让上下文失去一致性。

Normalization:归一化是把不同工具抛出的异常转换为稳定、可序列化的错误结果。生产环境还应进行错误脱敏,避免把路径、凭据或内部实现细节发送给模型。

最大 Step 是最基本的资源保护。模型可能因为提示、模型行为或工具反馈不断调用工具。没有上限时,一个逻辑错误会持续消耗模型请求、时间和宿主资源。

除了失败和资源上限,Agent Loop 还必须响应调用方取消。取消不能只改变 Turn 状态,还要传到正在执行的模型请求和工具。

Loop 接收调用方 AbortController 创建的 AbortSignal,并将同一个 Signal 传给模型调用和工具执行。它在每次模型调用和工具调用前后检查 Signal,可以阻止取消后继续启动工作。但这还不够:已经启动的长时间工具也必须监听 Signal,停止网络请求、定时器或子进程,并在清理后结束自己的 Promise。

Cooperative Cancellation:协作式取消通过 Signal 通知执行方停止,执行方负责清理资源并结束。Signal 不能强制终止一个完全忽略取消的任意 Promise。

因此,取消测试不能只断言 run() 已经拒绝,还需要确认:

  • 当前工具已经结束等待并释放资源。
  • 同一响应中的后续工具没有启动。
  • 下一次模型请求没有发生。
  • Step 与 Turn 均以 cancelled 结束。

Quiescence:静止状态,表示本次执行已经没有仍在运行、可能继续产生副作用的自有工作。对于可能失控的代码,需要使用可终止的 Worker 或子进程提供更强的隔离。

这些正常、失败和取消路径需要在可控条件下验证。ScriptedModel 按预先给定的顺序返回响应:

const model = new ScriptedModel([
  callTool("call-1", "read_memory", { key: "base" }),
  callTool("call-2", "calculator", { expression: "6 * 7" }),
  answer("base 是 10,6 × 7 是 42,所以结果是 52。"),
])

它不是在模拟模型智能,而是在固定 Agent Loop 的外部输入。这样可以稳定制造连续工具调用、未知工具和最大 Step 等场景,也能检查每一次 Model Request 的完整消息。

Deterministic Test:确定性测试在相同输入与初始状态下总能得到相同结果。它用于证明控制逻辑,不用于评价真实模型的工具选择能力。

五、可观察的执行与 DeepSeek Harness 对照

最终回答不足以解释一次 Agent 执行。相同答案可能来自直接生成,也可能来自三次工具调用;发生错误时,只有最终异常更无法说明任务停在哪个阶段。

Loop 因此记录以下事件:

事件表达的事实
turn/startturn/end一次用户任务的执行范围
step/startstep/end一次模型请求的执行范围
model/request实际发送给模型的上下文与工具集合
assistant/message模型本次返回的完整消息
tool/call已接受的工具调用请求
tool/result与请求对应的模型可见结果

一次两工具、三 Step 的轨迹如下:

sequenceDiagram
    participant AgentLoop
    participant Model
    participant Tool

    Note over AgentLoop: turn/start
    Note over AgentLoop: step/start step=1
    AgentLoop->>Model: model/request
    Model-->>AgentLoop: assistant/message
    AgentLoop->>Tool: tool/call read_memory call-1
    Tool-->>AgentLoop: tool/result read_memory call-1 result=10
    Note over AgentLoop: step/end step=1

    Note over AgentLoop: step/start step=2
    AgentLoop->>Model: model/request
    Model-->>AgentLoop: assistant/message
    AgentLoop->>Tool: tool/call calculator call-2
    Tool-->>AgentLoop: tool/result calculator call-2 result=42
    Note over AgentLoop: step/end step=2

    Note over AgentLoop: step/start step=3
    AgentLoop->>Model: model/request
    Model-->>AgentLoop: assistant/message calls=0 content=结果是52
    Note over AgentLoop: step/end step=3
    Note over AgentLoop: turn/end status=completed

轨迹经过了压缩,但保留了关键边界。实际事件还带有递增序号、时间戳、Turn ID 和 Step 编号。

Structured Event:具有稳定事件名称和字段的数据记录。终端轨迹只是它的一种显示方式;UI、测试和调试工具可以使用同一组事件生成不同视图。

当前事件只保存在 Turn 内存中,还不是持久化 Session Log。因此它可以用于观察和测试,但不能支持进程重启后的恢复、分叉和重放。

运行轨迹也说明了为什么 while (true) 不等于可靠 Agent Loop。循环语法只表示程序会重复执行,没有回答以下问题:

  1. 什么数据表示任务已经完成?
  2. 工具结果如何回到模型上下文?
  3. 工具失败与模型失败是否采用相同处理?
  4. 模型持续调用工具时,谁负责终止?
  5. 取消后,已经启动和尚未启动的工作分别怎样处理?
  6. 发生故障时,能否确定最后完成的执行边界?

对应的处理方式是:没有 Tool Call 时正常完成;Tool Result 作为 Message 回传;可恢复错误与驱动错误分层;maxSteps 限制请求次数;AbortSignal 贯穿模型和工具;结构化事件记录 Turn、Step 和工具边界。

只有这些约束共同成立,循环才具有可靠的执行语义。

把这个最小实现放回 DeepSeek Harness,可以看到两者使用相同的 Turn 和 Step 语义:Step 是一次模型请求及其工具调用,Turn 是零到多个 Step。当前实现只是这条执行主干的缩小版本。

最小实现DeepSeek Harness 对应部分完整 Harness 增加的能力
ModelAdapter.complete()LLM Adapter / llm/stream流式响应、模型选择和请求扩展点
Tool Registry工具 Schema 与执行服务按 Agent 限制工具可见性
工具执行Tool Execution Pipeline策略、审批、超时和结果处理
Turn / Stepturn/* / step/*持久化 Session Event
RunEventSession Event 与 Agent Event区分持久事实和运行期控制
AbortSignalAgent 与工具取消链生命周期释放和跨环境取消

Tool Execution Pipeline:工具调用依次经过策略检查、实际执行、结果处理和记录,而不是从模型输出直接跳到副作用。这样权限、审批、超时和监控可以独立扩展。

Sandbox:限制代码可访问文件、网络和进程等资源的执行环境。Sandbox 约束执行环境,但不能代替工具授权与参数校验。

Tool Result 已经进入后续模型请求,但上下文尚未从持久化 Session Event 重建,因此还不能支持恢复和重放。

六、结论与边界

这个最小实现可以证明:

  • Agent Loop 可以独立于具体模型 API。
  • Tool Call、宿主执行和下一次模型请求形成完整链路。
  • 未知工具、工具异常、最大 Step 和取消具有明确结果。
  • 同一套 Loop 可以使用确定性模型或真实 DeepSeek Adapter。
  • Turn、Step 和工具执行过程可以被检查。

最后可以提炼出五条工程原则:

  1. 模型负责生成下一步输出,Harness 负责执行语义。
  2. Tool Call 是请求,Tool Result 才是执行事实,两者必须通过调用编号关联。
  3. 工具结果无论成功还是失败,都要成为模型可见的结构化消息。
  4. Agent Loop 必须同时具备终止条件、资源上限、错误分类、取消传播和结构化事件。
  5. 确定性控制流测试与真实 API 集成分别验证不同问题,应同时保留。