系列文章:从零实现一个通用智能体
本篇主题:模型请求、工具执行、结果回填与 turn 状态机
代码版本:mx-claw 0.1.1
模型只会生成文本。当它尝试修改文件、搜索代码或运行命令时,Agent 必须把这些意图转换成 受控动作,再把动作结果送回模型。一次请求可能因此经过多轮“模型响应 → 工具执行 → 工具 结果 → 下一轮模型响应”。
mx-claw 将这个过程实现为 AgentCore.submitTurn() 的异步事件流。方法返回的不是一段最终
文本,而是持续产生的 turn、message、tool、permission 和 usage 事件:
tsfor await (const event of core.submitTurn(sessionId, input)) {
render(event)
}
CLI、HTTP SSE 和 Web 都只是这一事件流的不同消费者。
Query Loop 不允许直接启动。入口先确认三件事:
核心判断如下:
tsif (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 中止。
取得租约后,会话进入 running,并产生 turn.started 事件。系统提示只在新会话首次执行
时写入:
tsif (messages.length === 0) {
const systemMessage = {
role: 'system',
content: DEFAULT_SYSTEM_PROMPT,
}
await this.#store.appendMessage(sessionId, systemMessage)
messages = [systemMessage]
}
Skill 索引和长期记忆也在这一阶段注入。两者只在新会话的首回合读取,之后不再随磁盘内容 实时变化。这个冻结规则让同一会话内的行为基准保持稳定。
用户消息在模型调用前落盘。即使 Provider 随后超时或断线,输入仍然保存在会话事实源中。
工具循环需要明确上限。mx-claw 的默认最大模型迭代次数是 8:
tsconst maxIterations = Math.max(
1,
options.maxIterations ?? this.#defaultMaxIterations,
)
for (let iteration = 0; iteration < maxIterations; iteration += 1) {
// 模型请求与工具执行
}
throw new Error(`Maximum agent iterations reached (${maxIterations})`)
达到上限被视为显式错误,不会返回一个语义模糊的“已完成”。这样可以区分两种情况:
Provider 通信层把 OpenAI-compatible 流转换成结构化事件。Query Loop 分别处理三类数据:
| 事件 | 处理方式 |
|---|---|
text.delta | 立即产生 message.delta,供界面流式显示 |
tool_call.completed | 收集完整工具调用,等待流结束后统一处理 |
usage | 累加输入、输出和缓存 token,并计算预估成本 |
文本增量只影响界面,完整 assistant 消息在流结束后写入一次。工具是否存在以结构化
tool_call.completed 为准,而不是依赖 finishReason 的文本值。
tsif (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 事件并返回。
模型可能在一个响应中返回多个工具调用。执行策略取决于工具风险:
并行资格判断如下:
tsif (!tool?.concurrencySafe || tool.risk !== 'read') {
return null
}
并行只减少独立读取的等待时间,不改变安全边界。写操作仍保持严格顺序,避免两个工具基于 同一个旧文件内容做覆盖。
每个工具调用依次经过:
tool.started;tool.completed 和对应 tool result。schema 校验发生在权限判断之前。非法参数不会生成无意义的审批弹窗,也不会接触文件系统。
工具执行失败不会被直接抛给 UI。异常被转换为模型可以读取的 tool result:
textTool failed: <reason>
这样模型可以修正参数、换一个工具或停止,而不是让整个会话因为一次工具错误失去恢复机会。
相同工具与相同参数在同一个 turn 中第二次出现时,不会再次执行:
tsconst 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() 会:
补齐内容明确说明执行被中断:
textTool execution was interrupted before a result was recorded. This call must be retried if still needed.
修复只作用于 Provider 请求视图,原始 transcript 仍然保留。
取消信号会在三个位置检查:
任何未恢复异常都会把会话设为 failed,清理待审批请求并释放租约。消费者提前停止迭代时,
finally 同样执行清理。
tsfinally {
this.#activeTurns.delete(sessionId)
clearInterval(leaseHeartbeat)
await this.#store.releaseLease(sessionId, leaseOwner)
}
这种处理保证“界面已断开”不会留下一个继续执行的后台 turn,也不会让租约永久占用会话。
Query Loop 的核心不是 while,而是四个边界:
下一篇解释上下文管理:完整历史已经保存在 SQLite 中,模型请求为什么仍然需要压缩。


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