跳转到内容

Agent 循环

Agent loop 是 SDK 的核心。它负责把用户输入、模型回复、工具调用和工具结果串成一个稳定的循环。

一次 agent.query() 大致会经历这些步骤:

  1. 追加用户消息到内存对话。
  2. 调用 Anthropic 兼容 Messages API。
  3. 如果模型返回普通文本,输出 result 并结束。
  4. 如果模型返回 tool_use,SDK 找到对应工具并执行。
  5. SDK 把工具结果作为 tool_result 回填给模型。
  6. 模型继续推理,直到没有新的工具调用,或达到 maxTurns

工具也可以主动结束循环:批次中任一工具返回 endTurn: true 时,SDK 仍会把该批次的全部 tool_result 写入历史,然后直接以 subtype: "success" 收尾——第 6 步不再发起模型请求,该工具的内容成为结果文本。

需要 0.16.0 及以上版本。

需要 0.20.0 及以上版本。

当一次 run 必须以校验过的、带类型的 payload 收尾而不是一段自由文本时,设置 AgentOptions.outputSchema。SDK 会注入内置的 submit_output 工具(名字导出为 SUBMIT_OUTPUT_TOOL_NAME),其 input schema 就是你的 schema 转成的 JSON Schema——OutputSchema<T> 就是 { parse(input: unknown): T },zod schema 直接可用。

它会改变 loop 允许的收尾方式:

  • 模型通过调用 submit_output 来交卷。payload 先按 schema 校验;校验失败会作为 error tool_result 打回 loop,模型可以修正后重试。校验通过则以 subtype: "success" 结束,payload 落在 SDKResultMessage.structuredResult
  • 模型没调用 submit_output 就直接结束回合,run 失败:subtype: "error_missing_output"errorMissingOutputError。没有「把最终文本当 JSON 解析」的后门——提交是一个显式动作。
  • submit_output 必须独占一个 batch:同批混入其它工具调用、或同批出现两次提交,整批被拒绝(code submit_output_exclusive_batch),loop 继续。
  • outputSchema 设置期间该名字被保留——注册自己的 submit_output 工具会让 createAgent/addTools 直接抛错。

结构由 harness 把守,而不是靠 prompt 纪律;它只要求模型会 tool call——与 outputFormat 不同,不依赖各 provider 的 response_format/json_schema 特性(见 Provider 兼容性)。

同一个 Agent 实例会在内存里保存 conversation messages:

await agent.prompt("我的名字是 Ada。");
const result = await agent.prompt("我叫什么?");

这个状态默认只存在于当前进程和当前实例里。需要持久化 transcript 或在其他进程 resume 时,给 Agent 挂一个 HistoryStore —— 见下文「持久化历史与 resume」。

需要 0.10.0 及以上版本。

Agent 是一个会话,不是可复用的客户端。对话历史是实例状态,因此在一次 query 还没结束时再发起第二次,两个会话的轮次会互相穿插。SDK 会用 ConcurrentQueryError 拒绝第二次调用,而不是让两边的对话都被污染。

并发多少个会话,就创建多少个 Agent。在服务端意味着按请求或按用户会话构造,而不是共享一个模块级实例:

// 错误:每个请求都往同一份历史里追加。
const shared = createAgent({ model: "claude-sonnet-4-6" });
app.post("/ask", async req => shared.prompt(req.body.question));
// 正确:一个调用方一个会话。
app.post("/ask", async req => {
const agent = createAgent({ model: "claude-sonnet-4-6" });
return agent.prompt(req.body.question);
});

顺序复用没有问题,多轮对话正是这样工作的——等上一次 query 结束再发起下一次,就像上面那个记名字的例子。query 结束时守卫会释放,出错和中断也一样。

需要 0.16.0 及以上版本。

历史默认只保存在内存里。挂一个 HistoryStore 可以从持久存储里 seed 历史,并把之后的每次写入同步回去:

import { createAgent, createJsonlHistoryStore } from "agent-lattice";
const agent = createAgent({
model: "claude-sonnet-4-6",
historyStore: createJsonlHistoryStore({ path: ".agent-sessions/ada.jsonl" }),
});
// 首次 query 会惰性加载 store 里已有的历史,并在此基础上继续。
await agent.prompt("我叫什么?");
// 当前历史的深拷贝,可以随意修改。
const transcript = await agent.getHistory();

load() 在每个 Agent 生命周期里只跑一次,发生在首次 query 之前。跨进程 resume 就是「新 Agent + 同一个 store」——上面「一个 Agent 只承载一个会话」的语义不变。加载之后,每条进入历史的消息都会触发 append(message);compaction 整体重写历史时触发 replace(messages),所以 store 必须支持整体替换。createJsonlHistoryStore() 每行写一个 JSON message,load 时跳过损坏行,写文件时最后一次写入被截断不会丢掉整个 transcript。默认情况下 store 出错会被吞掉,对话降级为仅内存继续;在创建 store 时绑定 failOnError: true(经 createJsonlHistoryStore() 的 options 或 defineHistoryStore())可以让错误从 query() 传播出来。

宿主也可以用 agent.replaceHistory(messages) 直接改写历史。需要 0.17.0 及以上版本。 它仅限空闲时调用——query 运行中调用会抛 ConcurrentQueryError——配置了 historyStore 时 store 会同步替换,持久化保持一致。SDK 不校验内容:由宿主负责其合法性,替换进去的历史必须是结构良好的(例如不能有悬空的 tool_use 缺少对应的 tool_result)。

requestTimeoutMs 需要 0.11.0 及以上版本。

两种限制作用在不同的尺度上,长任务 agent 通常两个都需要。

QueryOptions.signal 约束整个 query —— 所有模型请求、工具执行和轮次加在一起:

const result = await agent.prompt("审计这个仓库。", {
signal: AbortSignal.timeout(600_000),
});
// result.subtype === "error_abort"

requestTimeoutMs 约束的是单次模型请求。一个正常需要跑二十轮工具的 agent,不必让这二十轮共享同一份预算:

const agent = createAgent({
model: "claude-sonnet-4-6",
requestTimeoutMs: 120_000,
});
// 也可以按 query 覆盖 agent 默认值。
await agent.prompt("快速回答。", { requestTimeoutMs: 15_000 });

超时会得到 subtype: "error_timeout" 和一个 TimeoutError,与调用方主动取消产生的 "error_abort" 区分开——宿主可以只重试超时,而不重试用户有意的取消。取值必须是正整数毫秒。不设置时 SDK 不施加自己的期限,由 provider 客户端的默认值兜底。

两种限制都由 SDK 自己兜底,不外包给 model client。 ModelRequest 会带上 signaltimeoutMs 供客户端取消自己的工作,但 agent 循环同时也会和它赛跑:一个两者都不理会的 ModelClient 无法让循环无限挂起。赛跑失败意味着放弃等待而不是取消——不守规矩的客户端可能仍在后台跑,只是循环不再等它。

需要 0.16.0 及以上版本。

QueryOptions.signal终止:query 以 subtype: "error_abort" 结束,适用于取消场景。当宿主想的是接管对话——用户改口了、来了更高优先级的指令——用 agent.interrupt():当前 query 以 subtype: "interrupted" 结束,这属于正常控制流而不是错误(is_error 仍为 false):

const pending = agent.prompt("起草发布说明。");
agent.interrupt();
const result = await pending;
// result.subtype === "interrupted"
// 同一个 Agent、同一份历史:注入新消息继续聊。
await agent.prompt("改一下:跳过 0.15.x,只写 0.16.0。");

损失的只有在飞的一轮:部分 assistant 消息与 abort 一样被丢弃,而每个已完成的轮次都留在历史里,后续 query 会把完整的前序对话发给模型。如果 interrupt 落在工具批次执行期间,则在该批次完成后生效——批次的 tool_result 先写入历史,query 在下一次模型调用之前以 "interrupted" 收尾。没有在飞的 query 时,interrupt() 是空操作。

需要 0.10.0 及以上版本。

每个 result 都带 usage(该次 query 内所有模型请求的合计)和最后一次响应的 stop_reason

const result = await agent.prompt("总结这个文件。");
console.log(result.usage); // { input_tokens, output_tokens, cache_read_input_tokens?, ... }
if (result.stop_reason === "max_tokens") {
// subtype 仍然是 "success",但 result 是被截断的片段,不是完整答案。
}

stop_reason: "max_tokens" 表示模型在输出预算用尽时被截断。SDK 不把它当作错误,所以检查这个字段是区分完整答案和截断片段的唯一方式。遇到时调大 maxTokens 或让模型少输出。

如果携带工具调用的响应在 max_tokens 处被截断,SDK 不会执行这批调用:末尾 tool_use 的入参可能不完整,被截断的值甚至可能通过 JSON 解析但语义已变。批次里的每个调用都会收到一条说明截断情况的错误 tool_result,要求模型缩短输出后重新发起调用,随后 loop 继续。

需要 0.18.0 及以上版本。

用量由 model client 上报。内置的 Anthropic 客户端会填好;自定义 ModelClient 若不提供 usage,得到的是全零计数而不是报错。

assistant 消息还会携带 provider 响应元数据:providerResponseId(provider 分配的响应 id)和 model(实际服务该响应的模型,可能与请求的模型不同)。内置 Anthropic 客户端在流式和非流式请求中都会填好这两个字段;自定义 ModelClient 也可以在返回的 AssistantModelMessage 上设置它们。客户端不上报时两个字段都缺省。

需要 0.16.0 及以上版本。

需要 0.13.0 及以上版本;溢出后的兜底恢复需要 0.14.0。

历史只增不减,长时间运行的 agent 迟早会超出模型的上下文窗口。打开 autoCompact,SDK 会把较早的对话替换成由模型写出的摘要:

const agent = createAgent({
model: "claude-sonnet-4-6",
autoCompact: true, // 或 { thresholdTokens: 150_000, keepRecentMessages: 8 }
});

压缩发生在两轮之间——某次响应报告的输入 token 超过 thresholdTokens(默认 100000)之后。SDK 会把除最后 keepRecentMessages 条(默认 6)之外的全部消息做成摘要,然后用「摘要 + 保留的消息」重建历史。摘要外面包了一段说明,告诉模型压缩刚刚发生、请据此继续,因此下一轮是接着做任务,而不是从头开始。

与只影响单次请求的 onModelRequest hook 不同,它会改写存储的对话历史。这正是目的所在——节省必须跨轮次持续生效——但代价是被替换掉的轮次在 agent 里就没有了。

切分点的选择保证保留部分能独立成立:tool_result 绝不会与产生它的 tool_use 分离,因为模型 API 会直接拒绝这种配对残缺的请求。如果找不到既安全又还有内容可摘要的切点,就跳过这次压缩。

压缩本身要花一次模型调用。它的 token 会计入 result.usage,所以总账是诚实的;同时会输出一条 subtype: "compaction"system 消息:

for await (const message of agent.query("重构这个模块。")) {
if (message.type === "system" && message.subtype === "compaction") {
console.log(`压缩了 ${message.compacted_messages} 条消息`, message.usage);
}
}

触发依赖上报的用量,因此使用自定义 ModelClient 且不提供 usage 时永远不会触发压缩。可以用 prompt 覆盖摘要指令,内置的那份可以从 DEFAULT_COMPACTION_PROMPT 读到。

阈值只是预测,单个超大的工具结果仍可能让某次请求直接越过窗口。这时压缩会作为恢复手段再跑一次:先摘要,然后重试同一轮,而不是让整个 query 失败。两种信号会触发它:

信号 含义
stop_reason: "model_context_window_exceeded" 窗口在请求过程中耗尽。该响应不可用,不会被写入历史。
提示词过长的 API 错误 输入本身就超窗,请求在生成前被拒,因此压根拿不到 stop reason。

每次 query 只尝试恢复一次。如果摘要本身失败、或者已经没有可摘要的内容,会原样抛出最初的错误,而不是用一个派生出来的二次错误把它盖掉。

stop_reason: "max_tokens" 刻意不触发压缩。 它表示输出撞到了 maxTokens,而不是输入太大——模型有足够空间读,只是没空间写了。压缩历史并不会让答案变完整。正确做法是调大 maxTokens 或让模型少输出。

maxTurns 用来避免模型和工具无限循环。默认值是 50,统计的是模型请求次数,不是工具执行次数:

const agent = createAgent({
apiKey,
model,
maxTurns: 8,
});

达到上限后,SDK 会输出 error_max_turns 类型的 result