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

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

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

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

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

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

一、从模型调用到 Agent 执行

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

text 复制代码
用户输入
  → 组装模型请求
  → 模型返回 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 Call Assistant 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,因为工具结果返回后,通常还需要再次请求模型。

text 复制代码
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 组织成一个有边界的状态机:

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

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

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

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

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

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

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

ts 复制代码
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() 执行模型生成的表达式,都没有建立真实的运行时安全条件。

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

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

ts 复制代码
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:

json 复制代码
{
  "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 按预先给定的顺序返回响应:

ts 复制代码
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 / Step turn/* / step/* 持久化 Session Event
RunEvent Session Event 与 Agent Event 区分持久事实和运行期控制
AbortSignal Agent 与工具取消链 生命周期释放和跨环境取消

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 集成分别验证不同问题,应同时保留。
相关推荐
小小小小宇13 分钟前
Pi 手动添加
前端
plainGeekDev14 分钟前
Agent代码审查与批量修复流水线
agent·ai编程·claude
不加辣椒15 分钟前
第 4 章 什么是 Harness Engineering
人工智能
桃西西呀15 分钟前
上下文窗口都卷到 100 万了,大模型为什么还在为"位置"发愁?
人工智能·llm·ai编程
深蓝AI30 分钟前
Mem0 实战:给 AI 应用加上长期记忆,从 Hello World 到生产用法
agent
boooooooom31 分钟前
手把手做一个图 RAG 烹饪问答系统:Neo4j + Milvus + LLM 的工程实践
前端·javascript·后端
小小善后师32 分钟前
HID 设备对接技术解析:基于本地中间服务的 WebSocket 通信模式
前端
黄油面包34 分钟前
Codex 额度三天见底后,我重新做了一周预算
前端·人工智能
PedroQue9937 分钟前
v2.7.1:修复 H5 端返回死循环闪烁问题
前端·uni-app