Agent 循环
Agent loop 是 SDK 的核心。它负责把用户输入、模型回复、工具调用和工具结果串成一个稳定的循环。
一次 agent.query() 大致会经历这些步骤:
- 追加用户消息到内存对话。
- 调用 Anthropic 兼容 Messages API。
- 如果模型返回普通文本,输出
result并结束。 - 如果模型返回
tool_use,SDK 找到对应工具并执行。 - SDK 把工具结果作为
tool_result回填给模型。 - 模型继续推理,直到没有新的工具调用,或达到
maxTurns。
工具也可以主动结束循环:批次中任一工具返回 endTurn: true 时,SDK 仍会把该批次的全部 tool_result 写入历史,然后直接以 subtype: "success" 收尾——第 6 步不再发起模型请求,该工具的内容成为结果文本。
需要 0.16.0 及以上版本。
用 submit_output 提交结构化输出
Section titled “用 submit_output 提交结构化输出”需要 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 校验;校验失败会作为 errortool_result打回 loop,模型可以修正后重试。校验通过则以subtype: "success"结束,payload 落在SDKResultMessage.structuredResult。 - 模型没调用
submit_output就直接结束回合,run 失败:subtype: "error_missing_output",error是MissingOutputError。没有「把最终文本当 JSON 解析」的后门——提交是一个显式动作。 submit_output必须独占一个 batch:同批混入其它工具调用、或同批出现两次提交,整批被拒绝(codesubmit_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」。
一个 Agent 只承载一个会话
Section titled “一个 Agent 只承载一个会话”需要 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 结束时守卫会释放,出错和中断也一样。
持久化历史与 resume
Section titled “持久化历史与 resume”需要 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 会带上 signal 和 timeoutMs 供客户端取消自己的工作,但 agent 循环同时也会和它赛跑:一个两者都不理会的 ModelClient 无法让循环无限挂起。赛跑失败意味着放弃等待而不是取消——不守规矩的客户端可能仍在后台跑,只是循环不再等它。
中断 query
Section titled “中断 query”需要 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() 是空操作。
Token 用量与截断
Section titled “Token 用量与截断”需要 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 及以上版本。
上下文自动压缩
Section titled “上下文自动压缩”需要 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 读到。
已经溢出之后的兜底恢复
Section titled “已经溢出之后的兜底恢复”阈值只是预测,单个超大的工具结果仍可能让某次请求直接越过窗口。这时压缩会作为恢复手段再跑一次:先摘要,然后重试同一轮,而不是让整个 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。