Agent SDK 的 turn 是”模型产出一段含 tool calls 的输出 + SDK 执行这些工具 + 把结果回传给模型”的完整一轮,它把”思考—行动—反馈”封装为一个不可拆分的执行单元。max_turns 只数包含工具调用的 turn,纯文本响应不计入计数,所以它的真实含义是”最多允许多少轮’做事’,而不是’说话'”。
一、turn 不是一个 HTTP 请求
很多第一次接触 Agent SDK 的工程师会把 turn 误读为”一次模型调用”。实际上一个 turn 内可能包含多个并行的 tool call,模型在一次响应里同时调用 Bash、Read 与 Edit 是常见现象。turn 的边界由”是否需要 SDK 介入执行外部动作”决定:只要模型希望 SDK 跑一段代码或读一个文件,循环就新增一个 turn;如果模型只输出文字回复,循环就停在当前 turn 并准备收尾。
这种切分方式让 turn 成为一个对资源敏感的单位:每个 turn 都意味着 token 重新组装、工具进程拉起、权限校验。生产环境把 max_turns 作为防御性兜底,本质是限制”做事”的次数,而不是”回复”的次数。
二、maxturns 与 maxbudget_usd 的差异
SDK 提供两个并行的兜底参数:max_turns 限定”做事”轮次,max_budget_usd 限定美元花费。两者触发后 SDK 都会返回 ResultMessage,但 error 子类型不同:命中 turn 上限是 error_max_turns,命中预算上限是 error_max_budget_usd。
| 维度 | max_turns | maxbudgetusd |
|---|---|---|
| 计数对象 | 含 tool call 的 turn | 累计 API 费用 |
| 纯文本 turn | 不计 | 计入(要花钱) |
| 触发后 error 子类型 | error_max_turns |
error_max_budget_usd |
| 典型取值 | 简单任务 5–10,复杂任务 30+ | 按任务单价预估 |
| 单位敏感性 | 受”单 turn 工具数”影响 | 受”模型选择 + 单价”影响 |
业务选型时通常把 max_turns 视作”防死循环”的硬上限,把 max_budget_usd 视作”防账单失控”的安全网。前者过松会让 agent 在错误路径上反复尝试,后者过紧会在合理任务中途截断。
三、为什么纯文本 turn 不计数
设计上有三层考虑。其一,计费模型上纯文本 turn 仍然要花钱,但相比工具 turn 它不会执行外部动作、不会改变系统状态,对”失控”的代表性更弱;其二,循环终止条件本身就是”模型产出不含 tool call 的回复”,把终止 turn 也算进去会让 max_turns=1 永远无法跑出有效结果;其三,调用方通常关心的是”agent 还能折腾几次”,纯文本 turn 是一种”自我收敛”的信号,不应消耗预算。
一个真实任务”修复 auth.ts 失败用例”完整跑完会消耗 4 个 turn:3 个含 tool call(跑测试、读文件、编辑 + 重跑),1 个含最终纯文本总结。设 max_turns=2 会在 Edit 步骤前停下,整个修复无法完成。这正是 max_turns 只数工具 turn 带来的可预期行为。
# 捕获 turn 上限触发后的处理模板
from claude_agent_sdk import query, ClaudeAgentOptions
async def run_with_cap(prompt: str):
options = ClaudeAgentOptions(
max_turns=30, # 工具 turn 上限
max_budget_usd=1.50, # 预算硬上限
allowed_tools=["Read", "Edit", "Bash"],
)
async for msg in query(prompt=prompt, options=options):
if msg.type == "result":
if msg.subtype == "error_max_turns":
return {"status": "truncated_turns", "text": msg.result}
if msg.subtype == "error_max_budget_usd":
return {"status": "truncated_budget", "text": msg.result}
return {"status": "ok", "text": msg.result, "cost": msg.total_cost_usd}
四、按步骤配置兜底参数
- 预估合理上限:先看任务类型,文档问答设 5–10、批量代码改设 30+、长链路重构设 50+;
- 同时配 budget:纯文本 turn 也花钱,单靠
max_turns防不住高单价模型; - 捕获 error 子类型:在消费
ResultMessage时先判result.subtype,区分截断原因; - 失败重试策略:若截断原因是 turn 上限,可在提示词里告诉模型”先用 plan 模式列出步骤再动手”,降低单 turn 工具数。
五、loop 实际运行的消息流
query() 是一个异步迭代器,吐出的消息类型包含 SystemMessage(会话元信息,含 init 子类型)、AssistantMessage(模型产出,含文本与 tool call)、UserMessage(工具结果回传)、ResultMessage(终态,含 usage 与 cost)。turn 与消息类型并不一一对应:单个 turn 内可能同时产生多个 AssistantMessage(并行工具调用场景)。要把 turn 数计清楚,应当监听 tool_use 事件而不是把所有 AssistantMessage 都当 turn。
六、生产环境常见误用
把 max_turns 设为 1 几乎一定失败,因为模型需要至少一个工具 turn 去读懂上下文。把 max_turns 设得过大又会让”卡死重试”行为持续数小时。生产实践通常给 max_turns 一个偏紧的初始值(如 30),用 max_budget_usd 作为兜底,并配合 hook 在 PreToolUse 阶段拦截已知会失败的命令,把循环尽早掐断在工具执行前。
另一个常见误用是把”模型每条消息”等同于”turn”。在 SDK 的消息流里,AssistantMessage 是模型响应片段,tool_use 是其中一种 block;一次 AssistantMessage 内可能既有文本又有多个 tool call,整个回合仍只算 1 个 turn。如果按消息计数来限制循环,要么过早截断、要么永远不触发上限,与 SDK 的真实执行模型错位。
七、调试循环时要看哪些字段
ResultMessage 的字段几乎全部用来事后分析 turn 与预算:total_cost_usd 看实际花费、duration_ms 看单 turn 平均耗时、num_turns 看真实工具 turn 计数、error 字段(命中上限时填充)告知截断原因。把这些字段写入结构化日志,是判断”上限设得过紧还是过松”的客观依据。
到这里,turn 的精确定义与 max_turns 的计数边界就清楚了:turn 是”做事”的单位而不是”说话”的单位,预算与上限要分开配置,捕获终态时要看 error 子类型而不是只看消息流计数。
常见问题(FAQ)
Q1:max_turns=0 会发生什么?
模型没有任何工具 turn 可用,会立即进入终态的纯文本响应,几乎拿不到任何实际工作产出。
Q2:turn 上限触发后能不能续跑?
需要外部保存 session_id 并用持久化参数续接,单次 query 不支持”再加 5 个 turn”。
Q3:并行 tool call 算一个 turn 还是多个?
算一个 turn,只要它们在同一次模型响应里被请求,循环就只前进一次。