2026-10-23
AI
0

目录

开篇
进入循环前的约束
Turn 的初始状态
迭代预算
接收流式响应
没有工具调用时
多个工具调用
单个工具调用的生命周期
重复调用保护
工具结果必须闭合
取消与失败
小结

系列文章:从零实现一个通用智能体
本篇主题:模型请求、工具执行、结果回填与 turn 状态机
代码版本:mx-claw 0.1.1

开篇

模型只会生成文本。当它尝试修改文件、搜索代码或运行命令时,Agent 必须把这些意图转换成 受控动作,再把动作结果送回模型。一次请求可能因此经过多轮“模型响应 → 工具执行 → 工具 结果 → 下一轮模型响应”。

mx-claw 将这个过程实现为 AgentCore.submitTurn() 的异步事件流。方法返回的不是一段最终 文本,而是持续产生的 turn、message、tool、permission 和 usage 事件:

ts
for await (const event of core.submitTurn(sessionId, input)) { render(event) }

CLI、HTTP SSE 和 Web 都只是这一事件流的不同消费者。

进入循环前的约束

Query Loop 不允许直接启动。入口先确认三件事:

  1. 已配置模型 Provider;
  2. 当前会话没有本进程内的活跃 turn;
  3. 当前进程成功取得跨进程会话租约。

核心判断如下:

ts
if (this.#activeTurns.has(sessionId)) { throw new Error(`Session already has an active turn: ${sessionId}`) } const leased = await this.#store.acquireLease( sessionId, leaseOwner, this.#leaseTtlMs, ) if (!leased) { throw new Error(`Session lease is held by another process: ${sessionId}`) }

进程内集合阻止同一 Core 重叠执行,SQLite 租约阻止 CLI 与 Web 同时写一个会话。两层检查 对应两种并发范围,缺一不可。

租约不是一次性令牌。turn 执行期间会定时续租;续租失败意味着另一个执行者已经接管,当前 循环必须通过 AbortSignal 中止。

Turn 的初始状态

取得租约后,会话进入 running,并产生 turn.started 事件。系统提示只在新会话首次执行 时写入:

ts
if (messages.length === 0) { const systemMessage = { role: 'system', content: DEFAULT_SYSTEM_PROMPT, } await this.#store.appendMessage(sessionId, systemMessage) messages = [systemMessage] }

Skill 索引和长期记忆也在这一阶段注入。两者只在新会话的首回合读取,之后不再随磁盘内容 实时变化。这个冻结规则让同一会话内的行为基准保持稳定。

用户消息在模型调用前落盘。即使 Provider 随后超时或断线,输入仍然保存在会话事实源中。

迭代预算

工具循环需要明确上限。mx-claw 的默认最大模型迭代次数是 8:

ts
const maxIterations = Math.max( 1, options.maxIterations ?? this.#defaultMaxIterations, ) for (let iteration = 0; iteration < maxIterations; iteration += 1) { // 模型请求与工具执行 } throw new Error(`Maximum agent iterations reached (${maxIterations})`)

达到上限被视为显式错误,不会返回一个语义模糊的“已完成”。这样可以区分两种情况:

  • 模型不再请求工具,任务自然完成;
  • Agent 仍在循环,但预算已经耗尽。

接收流式响应

Provider 通信层把 OpenAI-compatible 流转换成结构化事件。Query Loop 分别处理三类数据:

事件处理方式
text.delta立即产生 message.delta,供界面流式显示
tool_call.completed收集完整工具调用,等待流结束后统一处理
usage累加输入、输出和缓存 token,并计算预估成本

文本增量只影响界面,完整 assistant 消息在流结束后写入一次。工具是否存在以结构化 tool_call.completed 为准,而不是依赖 finishReason 的文本值。

ts
if (chunk.type === 'text.delta') { assistantTextParts.push(chunk.text) yield await this.#createEvent(sessionId, turnId, { type: 'message.delta', messageId: assistantMessageId, delta: chunk.text, }) }

流结束后,文本和 tool calls 被组装成一个 assistant 消息,先持久化,再决定是否继续执行。

没有工具调用时

当本轮只有文本、没有工具调用,Query Loop 将会话状态改为 idle,保存会话,然后执行两类 后置观察:

  • 从用户文本中识别显式记忆请求;
  • 从成功工具轨迹中观察是否形成可复用工作流。

两类学习都只生成候选,失败也不会把已经完成的主任务改成失败。最后产生 turn.completed 事件并返回。

多个工具调用

模型可能在一个响应中返回多个工具调用。执行策略取决于工具风险:

  • 全部是只读、并发安全且参数有效的工具时,可以并行执行;
  • 包含写文件、命令或任何非并发安全工具时,整批调用按协议顺序串行执行。

并行资格判断如下:

ts
if (!tool?.concurrencySafe || tool.risk !== 'read') { return null }

并行只减少独立读取的等待时间,不改变安全边界。写操作仍保持严格顺序,避免两个工具基于 同一个旧文件内容做覆盖。

单个工具调用的生命周期

每个工具调用依次经过:

  1. 查找工具;
  2. 检查重复调用;
  3. 解析 JSON 参数;
  4. 执行 Zod schema 校验;
  5. 生成人类可读摘要;
  6. 运行 PreToolUse Hook;
  7. 执行权限决策;
  8. 发出 tool.started;
  9. 执行工具;
  10. 运行 PostToolUse Hook;
  11. 写入 tool.completed 和对应 tool result。

schema 校验发生在权限判断之前。非法参数不会生成无意义的审批弹窗,也不会接触文件系统。

工具执行失败不会被直接抛给 UI。异常被转换为模型可以读取的 tool result:

text
Tool failed: <reason>

这样模型可以修正参数、换一个工具或停止,而不是让整个会话因为一次工具错误失去恢复机会。

重复调用保护

相同工具与相同参数在同一个 turn 中第二次出现时,不会再次执行:

ts
const duplicateKey = `${toolCall.name}\u0000${toolCall.arguments}` if (seenToolCalls.has(duplicateKey)) { return 'Duplicate tool call rejected; use the previous result instead.' }

该检查主要处理模型陷入重复动作的情况。读操作重复执行只是浪费上下文,写操作和命令重复 执行则可能产生真实副作用。

工具结果必须闭合

OpenAI-compatible API 要求每个 assistant tool call 都有对应 tool result。进程崩溃、 消费者中止或 Provider 异常都可能留下不完整历史。

下一轮请求前,repairToolCallTranscript() 会:

  • 删除不属于当前工具段的孤立 tool result;
  • 移除重复 tool result;
  • 按原始 tool call 顺序补齐中断结果;
  • 保留仍然有效的 assistant/tool 配对。

补齐内容明确说明执行被中断:

text
Tool execution was interrupted before a result was recorded. This call must be retried if still needed.

修复只作用于 Provider 请求视图,原始 transcript 仍然保留。

取消与失败

取消信号会在三个位置检查:

  • 进入 turn 前;
  • 每次迭代开始前;
  • 流事件和工具执行过程中。

任何未恢复异常都会把会话设为 failed,清理待审批请求并释放租约。消费者提前停止迭代时, finally 同样执行清理。

ts
finally { this.#activeTurns.delete(sessionId) clearInterval(leaseHeartbeat) await this.#store.releaseLease(sessionId, leaseOwner) }

这种处理保证“界面已断开”不会留下一个继续执行的后台 turn,也不会让租约永久占用会话。

小结

Query Loop 的核心不是 while,而是四个边界:

  • 单会话单写者;
  • 每个工具调用都有闭合结果;
  • 只读调用可以并行,有副作用的调用必须串行;
  • 自然完成、预算耗尽、取消和异常拥有不同的显式状态。

下一篇解释上下文管理:完整历史已经保存在 SQLite 中,模型请求为什么仍然需要压缩。

如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:繁星

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!