调用一次大模型 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.id和ToolResult.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 保存两类信息:
- 发给模型的工具名称、用途说明和输入 Schema。
- 仅由宿主持有的执行函数。
模型只能看到第一类信息。函数对象、文件句柄或宿主私有状态不能进入模型请求。
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/start、turn/end |
一次用户任务的执行范围 |
step/start、step/end |
一次模型请求的执行范围 |
model/request |
实际发送给模型的上下文与工具集合 |
assistant/message |
模型本次返回的完整消息 |
tool/call |
已接受的工具调用请求 |
tool/result |
与请求对应的模型可见结果 |
一次两工具、三 Step 的轨迹如下:
轨迹经过了压缩,但保留了关键边界。实际事件还带有递增序号、时间戳、Turn ID 和 Step 编号。
Structured Event:具有稳定事件名称和字段的数据记录。终端轨迹只是它的一种显示方式;UI、测试和调试工具可以使用同一组事件生成不同视图。
当前事件只保存在 Turn 内存中,还不是持久化 Session Log。因此它可以用于观察和测试,但不能支持进程重启后的恢复、分叉和重放。
运行轨迹也说明了为什么 while (true) 不等于可靠 Agent Loop。循环语法只表示程序会重复执行,没有回答以下问题:
- 什么数据表示任务已经完成?
- 工具结果如何回到模型上下文?
- 工具失败与模型失败是否采用相同处理?
- 模型持续调用工具时,谁负责终止?
- 取消后,已经启动和尚未启动的工作分别怎样处理?
- 发生故障时,能否确定最后完成的执行边界?
对应的处理方式是:没有 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 和工具执行过程可以被检查。
最后可以提炼出五条工程原则:
- 模型负责生成下一步输出,Harness 负责执行语义。
- Tool Call 是请求,Tool Result 才是执行事实,两者必须通过调用编号关联。
- 工具结果无论成功还是失败,都要成为模型可见的结构化消息。
- Agent Loop 必须同时具备终止条件、资源上限、错误分类、取消传播和结构化事件。
- 确定性控制流测试与真实 API 集成分别验证不同问题,应同时保留。