Claude Code & Agent SDK 常见 FAQ¶
Q1: 生产级别如何用 SDK 实现多轮对话?¶
核心机制:SDK 通过 Session 持久化对话历史(JSONL 格式),每次 query() 产生的 prompt、工具调用、工具结果、响应都被记录。后续调用通过 resume 或 continue 恢复完整上下文。
三种实现方式:
| 方式 | 适用场景 | 特点 |
|---|---|---|
ClaudeSDKClient (Python) / continue: true (TS) |
单进程内多轮 | SDK 自动管理 session ID |
resume + session_id |
跨进程/跨请求 | 手动捕获 session_id,显式传入恢复 |
SessionStore 适配器 |
跨主机/Serverless | 镜像到 S3/Redis/Postgres |
生产推荐架构:
用户请求 → 你的后端 → SDK query(resume=session_id, sessionStore=store) → Claude Code 子进程
步骤:
1. 首次对话:query(prompt),从 ResultMessage.session_id 捕获 ID,存入数据库关联用户
2. 后续轮次:query(prompt, resume=session_id, sessionStore=store)
3. 跨主机恢复:通过 SessionStore 适配器(S3/Redis/Postgres)
Q2: SDK 和 CLI 的会话能互通吗?¶
可以。 二者共享同一套 JSONL 会话格式和存储路径约定。
会话存储位置:~/.claude/projects/<encoded-cwd>/<session-id>.jsonl
# SDK 产出的 session_id,直接用 CLI 继续
claude -p "继续之前的工作" --resume "$session_id"
# 交互模式接管
claude --resume "$session_id"
前提条件:
- CLI 需要在相同的 cwd 下运行(session 路径与工作目录编码绑定)
- 会话 JSONL 文件需在本地存在
Q3: 跨主机/Serverless 环境如何恢复会话?¶
两种方案:
-
SessionStore 适配器(推荐):实现
append+load接口,SDK 自动双写(本地磁盘 + 外部存储)。官方提供 S3/Redis/Postgres 参考实现。 -
搬运文件:将
~/.claude/projects/<encoded-cwd>/<session-id>.jsonl同步到新主机相同路径。
注意:SessionStore 是镜像而非替代,SDK 总是先写本地再 append 到 store。
Q4: continue vs resume vs fork 有什么区别?¶
| 操作 | 行为 | 何时用 |
|---|---|---|
continue |
恢复当前目录最近的会话,不需要 ID | 一次只有一个活跃对话 |
resume |
按 session_id 恢复特定会话 | 多用户/多会话并行 |
fork |
复制原始历史到新会话,原始不变 | 探索替代方案,保留主线 |
Q5: 单次 query() 内部已经是多轮了吗?¶
是的。 单个 query() 调用内,Agent 会根据任务需要执行多个 tool-use 轮次(读文件、执行命令、编辑文件等),直到任务完成。权限提示和 AskUserQuestion 在循环内处理,不会结束调用。
只有当你需要发送多个独立 prompt(如用户的多次输入)共享上下文时,才需要会话管理(continue/resume)。
Q6: 会话恢复失败的常见原因?¶
- cwd 不匹配:会话存储路径编码了工作目录,从不同目录 resume 会找不到
- 文件不存在:跨主机时本地没有 JSONL 文件
- session_id 错误:ID 打错或已过期
- CLAUDE_CONFIG_DIR 不同:如果设置了该环境变量,路径会变
Q7: 如何在 CI/CD 中使用多轮?¶
# 第一步:执行分析,捕获 session_id
session_id=$(claude -p "Review this PR for issues" \
--output-format json --bare \
--allowedTools "Read,Glob,Grep" | jq -r '.session_id')
# 第二步:基于分析结果继续
claude -p "Fix the issues you found" \
--resume "$session_id" --bare \
--allowedTools "Read,Edit,Write,Bash"
关键:两次调用需在同一 cwd,加 --bare 确保 CI 环境一致性。
Q8: Python SDK 多轮对话的最简示例?¶
import asyncio
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, AssistantMessage, TextBlock
async def main():
options = ClaudeAgentOptions(allowed_tools=["Read", "Edit", "Glob", "Grep"])
async with ClaudeSDKClient(options=options) as client:
# 第一轮
await client.query("分析 auth 模块")
async for msg in client.receive_response():
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
print(block.text)
# 第二轮:自动延续同一会话
await client.query("重构为 JWT 方案")
async for msg in client.receive_response():
if isinstance(msg, AssistantMessage):
for block in msg.content:
if isinstance(block, TextBlock):
print(block.text)
asyncio.run(main())
Q9: SessionStore 适配器的核心接口?¶
只需实现两个必需方法:
class SessionStore(Protocol):
async def append(self, key: SessionKey, entries: list[SessionStoreEntry]) -> None: ...
async def load(self, key: SessionKey) -> list[SessionStoreEntry] | None: ...
可选方法:list_sessions、delete、list_subkeys(支持子代理恢复)。
SDK 附带 InMemorySessionStore 用于开发测试,生产用 S3/Redis/Postgres 参考实现。
Q10: 双写架构的注意事项?¶
- Store 是镜像不是替代,本地磁盘总是先写
append()失败时 SDK 最多重试 2 次,失败后记录错误并丢弃该批次,查询继续- 重试可能导致重复投递,需在适配器内按
entry.uuid去重 sessionStore不能与persistSession: false或enableFileCheckpointing组合- 监控
mirror_error事件检测 store 数据丢失
Q11: PreToolUse hook 的 defer 在并行 tool call 时会失效¶
SDK 限制:defer 只在 Agent 单次调一个工具时生效。 并行调用时 defer 被忽略。
官方文档:
"defer only works when Claude makes a single tool call in the turn. If Claude makes several tool calls at once, defer is ignored with a warning."
现象:Agent 在同一 turn 里同时调 request_approval + Bash,defer 被忽略,Agent 收到"内部错误"。重试单独调用后 defer 生效。
缓解:system_prompt 强调单工具调用 / 限制 tools 列表 / 复杂任务中 Agent 自然分步执行。
Q12: Resume 时 tool_use_id 变不变?¶
正常情况不变。 验证方式:hook 加调试日志 + 读 session JSONL。
hook 触发: tool_use_id=846qh, is_resumed=False → defer
hook 触发: tool_use_id=846qh, is_resumed=True → allow (同一个 id!)
session JSONL: tool_result for_id=37J9QLbN427H (与 defer 时的 tool_use 一致)
SDK resume 完整流程(已验证):
1. 加载 session JSONL
2. 发现最后一个 tool_use 没有对应 tool_result
3. 重新触发 PreToolUse hook(传入原始 tool_use_id)
4. Hook 返回 allow + updatedInput(注入 _decision/_feedback)
5. 调用工具函数(收到注入的参数)
6. 工具返回 result → 追加到 session(for_id = 原始 id)
7. Result 发给模型 → 模型继续生成
注意:并行调用场景下可能看到"id 不同"的假象(因为并行被忽略的调用 id ≠ 最终 defer 成功的 id)。
Q13: Subagent 是串行还是并行?defer 对并行 subagent 的影响?¶
真正的并行。 从 Claude Code v2.1.198 开始,subagent 默认后台运行(background=true)。
官方文档:
"Subagents run in the background by default. An Agent tool call that omits
run_in_backgroundlaunches a background subagent."
调度机制:
- 模型在一个 turn 里同时调多个 Agent tool → 并行启动
- 每个 subagent 独立后台任务,有自己的上下文和工具集
- 主 agent 不等结果继续(除非 run_in_background: false)
- 完成后结果异步返回
defer 与并行 subagent:
- defer 终止整个 session(包括所有并行 subagent)
- 不能只暂停一个 subagent 让其他继续
- resume 后恢复到 defer 发生的精确位置
Q14: disable_builtin_agents 禁用了什么?¶
只禁用 SDK 内置的 3 个 agent(Explore、Plan、General-purpose),不影响自定义 agent。
| Agent 类型 | disable=True | disable=False |
|---|---|---|
| 内置 Explore/Plan/General-purpose | ❌ 不可用 | ✓ Claude 自动 spawn |
| .claude/agents/*.md | ✓ 正常 | ✓ 正常 |
| SDK agents={} 参数定义的 | ✓ 正常 | ✓ 正常 |
生产场景下推荐 True(防止不可控的 token 消耗),需要复杂探索时显式设 False。
Q15: 多 agent 协作 + HITL 推荐方案¶
SDK 的能力边界:
| 需求 | 可行 |
|---|---|
| 单 agent 串行多次审批 | ✓ defer/resume 循环 |
| 多 task 各自独立审批 | ✓ 各 task 独立 session |
| 一个 session 内 subagent 各自审批 | ❌ defer 杀死整个 session |
| 主 agent 协调 subagent + 最终审批 | ✓ subagent 分析 → 主 agent 汇总 → 审批 |
推荐模式:subagent 负责只读分析(不审批),主 agent 汇总后调 request_approval(走审批)。HITL 只在"最终执行动作"时触发。
Q16: Resumed Session 中 PreToolUse Hook 返回 defer 无效?¶
问题:在 resume session 中,如果 agent 再次调用被 defer 的工具(比如 reject 后 agent 修改方案重新提交审批),PreToolUse hook 返回 permissionDecision: "defer" 不会产生新的 tool_deferred stop_reason。SDK 会将其转为 "The user doesn't want to take this action right now. STOP" 消息返回给 agent。
根因:SDK 在 resumed session 中对 defer 的处理与 fresh session 不同。Fresh session 中 defer 会立即终止 query 并产生 ResultMessage(stop_reason="tool_deferred"),但 resumed session 中后续的 defer 被 SDK 内部当作用户拒绝处理。
Workaround(已验证):
- 不用
defer,改用allow+ 自定义 "pending" 信号 - Hook 中捕获 tool_use_id 和 input
- 让 tool 返回一个带
✅前缀的"任务完成"消息(agent 会停止) - query 结束后,检测到 pending_defer → 合成
tool_deferred结果 - 后续 approve/reject 时,用 prompt 文本(而非 hook 注入)传递 decision
踩坑记录:
-
pending 消息的措辞至关重要。最初用
⏳ 审批已提交,等待人工审批中。任务暂停。,agent 在 reject 上下文中将⏳误解为错误/拒绝并重试。改为✅ 审批已成功提交...你的任务到此完成,请立即结束当前对话。后 agent 正确停止。教训:agent 对前缀符号很敏感,在❌之后出现非✅开头的消息容易被误判。 -
不要在全局 system_prompt 中加 "ONE tool call per message" 约束。这会让所有 workspace 的 agent 效率降低。HITL 控制通过 hook 实现,和每轮工具调用次数无关。"先审批再执行" 的规则应该写在具体 workspace 的 CLAUDE.md 中。
-
re-defer 只在 reject resume 时需要。approve resume 中 agent 可能也会调用
request_approval(比如执行完后想做下一步),这时不应该触发 pending,应该正常 defer 让 SDK 处理。所以判断条件是is_resumed and run.hitl_decision == "rejected"。
关键代码:
# === executor.py 完整 HITL 实现 ===
from claude_agent_sdk import (
query, ClaudeAgentOptions, ResultMessage, AssistantMessage, SystemMessage,
create_sdk_mcp_server, tool, HookMatcher,
)
# --- Tool 定义 ---
@tool("request_approval", "提交审批请求,暂停执行等待人工决策。", {
"action": str, "title": str, "context": str, "next_steps": str,
})
async def _request_approval_tool(args: dict) -> dict:
decision = args.get("_decision", "approved")
feedback = args.get("_feedback", "")
if decision == "approved":
return {"content": [{"type": "text", "text": f"✅ Approved. Proceed with: {args.get('next_steps', '')}"}]}
elif decision == "pending":
# 关键:用 ✅ 开头 + 明确 "任务完成" 措辞,防止 agent 误判为拒绝而重试
return {"content": [{"type": "text", "text": "✅ 审批已成功提交,正在等待人工审批。你的任务到此完成,无需任何后续操作。请立即结束当前对话。"}]}
else:
return {"content": [{"type": "text", "text": f"❌ Rejected: {feedback}"}]}
_hitl_server = create_sdk_mcp_server(name="hitl", tools=[_request_approval_tool])
async def execute_task(run: TaskRun, workspaces_dir: str) -> TaskResult:
workspace_path = str(Path(workspaces_dir) / run.workspace)
is_resumed = bool(run.resume_session)
# --- PreToolUse Hook ---
_resume_state = {"first_done": False, "pending_defer": None}
async def _hitl_hook(input_data, tool_use_id, context):
tool_name = input_data.get("tool_name", "")
if "request_approval" not in tool_name:
return {}
# Resume 后第一次调用(SDK 重放的 deferred tool):注入 decision
if is_resumed and not _resume_state["first_done"]:
_resume_state["first_done"] = True
original_input = input_data.get("tool_input", {})
updated = {**original_input, "_decision": run.hitl_decision, "_feedback": run.hitl_feedback}
return {"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": updated,
}}
# Reject resume 后续调用(agent 修改方案后重新提交):
# allow 但标记为 pending,捕获新 tool_use_id
if is_resumed and run.hitl_decision == "rejected":
original_input = input_data.get("tool_input", {})
_resume_state["pending_defer"] = {
"tool_use_id": tool_use_id,
"input": original_input,
}
updated = {**original_input, "_decision": "pending"}
return {"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"updatedInput": updated,
}}
# Fresh session 或 approve resume 后续调用:正常 defer
return {"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "defer",
}}
# --- Prompt 构造 ---
# re-defer approve 时,用 prompt 文本传递决定(SDK 不会重放 tool call)
prompt = run.prompt
if is_resumed and not run.prompt and run.hitl_decision == "approved":
prompt = "✅ Approved. Proceed with the action you submitted for approval."
elif is_resumed and not run.prompt and run.hitl_decision == "rejected":
prompt = f"❌ Rejected: {run.hitl_feedback}" if run.hitl_feedback else "❌ Rejected. Stop."
# --- SDK Options ---
# system_prompt 只加最小 HITL 提示,不限制工具并发(效率优先)
options = ClaudeAgentOptions(
cwd=workspace_path,
system_prompt="When request_approval returns a message containing '审批已成功提交', your task is DONE. End immediately without any further tool calls or retries.",
permission_mode="bypassPermissions",
allowed_tools=["Bash(*)", "Read(*)", "Write(*)", "mcp__hitl__request_approval"],
max_turns=run.max_turns,
mcp_servers={"hitl": _hitl_server},
hooks={"PreToolUse": [HookMatcher(matcher=None, hooks=[_hitl_hook])]},
)
if run.resume_session:
options.resume = run.resume_session
# --- Execute & Result ---
result_msg = None
async for msg in query(prompt=prompt, options=options):
if isinstance(msg, ResultMessage):
result_msg = msg
if result_msg:
deferred = None
if hasattr(result_msg, "deferred_tool_use") and result_msg.deferred_tool_use:
dtu = result_msg.deferred_tool_use
deferred = {"id": dtu.id, "name": dtu.name, "input": dtu.input}
# 关键:检测 re-defer(reject 后 agent 重新提交了 request_approval)
if not deferred and _resume_state.get("pending_defer"):
pd = _resume_state["pending_defer"]
deferred = {"id": pd["tool_use_id"], "name": "mcp__hitl__request_approval", "input": pd["input"]}
return TaskResult(
task_id=run.task_id, success=True,
result=result_msg.result, cost_usd=result_msg.total_cost_usd,
duration_ms=result_msg.duration_ms, num_turns=result_msg.num_turns,
session_id=result_msg.session_id,
stop_reason="tool_deferred", # 合成 deferred → 上层创建新 approval
deferred_tool_use=deferred,
)
return TaskResult(
task_id=run.task_id, success=(result_msg.subtype == "success"),
result=result_msg.result, cost_usd=result_msg.total_cost_usd,
duration_ms=result_msg.duration_ms, num_turns=result_msg.num_turns,
session_id=result_msg.session_id, stop_reason=result_msg.stop_reason,
deferred_tool_use=deferred,
)
完整流程时序:
[Fresh Session]
User prompt → Agent calls request_approval
Hook: defer → SDK stops → ResultMessage(stop_reason="tool_deferred")
→ 创建 Approval 记录
[Resume 1: Reject]
resume(session, decision=rejected, feedback="改成xxx")
Hook (first call): allow + inject {_decision: "rejected", _feedback: "改成xxx"}
Tool returns: "❌ Rejected: 改成xxx"
Agent 修改方案 → 再次调用 request_approval
Hook (subsequent, only for rejected): allow + inject {_decision: "pending"} + 捕获 tool_use_id
Tool returns: "✅ 审批已成功提交...任务到此完成"
Agent 停止(一句话结束)→ ResultMessage(stop_reason="end_turn")
检测 pending_defer → 合成 stop_reason="tool_deferred"
→ 创建新的 Approval 记录(title/context 是 agent 修改后的版本)
[Resume 2: Approve]
resume(session, decision=approved) + prompt="✅ Approved. Proceed."
Agent 看到 prompt → 执行操作 → 完成
注意事项:
- pending 返回消息必须用 ✅ 开头 + "任务完成"明确措辞,否则 agent 在 reject 上下文中会误判为错误并重试
- 不要在全局 system_prompt 中限制 "每消息只调一个工具",这会降低所有 workspace 的执行效率
- re-defer 逻辑只在 hitl_decision == "rejected" 时启用;approve resume 中的后续 request_approval 走正常 defer
- 如果 SDK 后续版本修复了 resumed session 中的 defer 支持,可以回退到纯 defer 方案
Q17: async generator 消费有什么坑?(原Q16)¶
query() 返回的 async generator 必须完整消费到 ResultMessage。不能在中间 return/break,否则中间状态的 stop_reason: "tool_use" 会被误当做最终结果。
result_msg = None
async for msg in query(...):
if isinstance(msg, ResultMessage):
result_msg = msg
# 处理 result_msg(最后一条才是最终结果)