系列第 11 篇。主文档见 智能体上下文工程实现.md。
agent 不是"提交一个请求等一个回复"那么简单。模型在 streaming 输出,用户随时可能 ESC 中断,工具可能跑到一半被 kill,部分 token 已发送但请求被取消 —— 这些都创造"半完成"状态。这一篇讲我(Claude Code)的运行时怎么处理这些时序复杂性,以保证上下文一致性。
0. 三种"半完成"
1. 输出 streaming 中:模型在边生成边发送 token
2. 工具执行中:工具调用已发起,结果未返回
3. 等待用户决策:权限提示、AskUserQuestion 弹出
任何一种状态被打断,都可能让上下文留下不一致的痕迹。设计良好的 harness 必须把每种状态的"安全中断点"想清楚。
1. Streaming 输出的本质
Anthropic API 默认以 SSE (Server-Sent Events) 流式返回内容。一次回复不是一个完整 JSON,而是一系列事件:
event: message_start
data: {"type": "message_start", "message": {...}}
event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}}
event: content_block_delta
data: {"type": "content_block_delta", "delta": {"text": "Let me"}}
event: content_block_delta
data: {"type": "content_block_delta", "delta": {"text": " check"}}
...
event: content_block_stop
event: message_stop
这意味着用户能"看着我打字",体验远好于等几十秒一次性出全文。但工程代价是:回复在传输过程中是部分完成的。
1.1 流式响应的三种内容
一次响应可能含:
- 纯文本(content_block type=text)
- 工具调用(content_block type=tool_use)
- 思考块(content_block type=thinking,仅 extended thinking 模型)
每种都可以被中断。最复杂的是 tool_use:
content_block_start: tool_use, name="Edit"
content_block_delta: input_json_delta = '{"file'
content_block_delta: input_json_delta = '_path":'
... [用户 ESC 在这里] ...
(永远不会到 content_block_stop)
此时 tool_use 的 input JSON 是不完整的 —— 没法执行。
2. 中断的分类
| 中断源 | 时机 | 副作用范围 |
|---|---|---|
| 用户 ESC | streaming 任意时刻 | 当前回合 |
| 用户 Ctrl+C | 工具执行中 | 当前工具进程 |
| API 连接断开 | streaming 任意时刻 | 当前请求 |
| 上下文过长 | 请求发送前 | 拒绝请求 |
| Hook 阻塞 | 工具执行前 | 工具调用 |
| 系统关机 | 任意时刻 | 整个 harness |
每种都有不同的"安全清理"流程。
3. ESC 中断 streaming 的处理
用户最常用的中断 —— ESC 键。harness 的处理:
def handle_esc_interrupt(state, current_response):
# 1. 立即停止从 SSE 读取
current_response.close()
# 2. 收集已收到的部分内容
partial = current_response.partial_content
# 3. 决定如何记录
if partial.has_text:
# 已经吐了一些文本 → 作为不完整 assistant 消息保留
state.messages.append(Message(
role="assistant",
content=[Block(type="text", content=partial.text + " [interrupted]")]
))
if partial.has_partial_tool_use:
# tool_use 不完整 → 不保留(无法执行也无法引用)
# 但要在下一轮告诉模型"上次有个工具调用被中断"
state.pending_injections.append(Block(
source="reminder",
content="Your previous turn was interrupted by user before completion. The user will provide further instructions."
))
# 4. 等待用户下一条输入
state.status = "waiting_user"
关键点:
- 已经发出的文本保留(用户看到了,agent 不能假装没说)
- 不完整的 tool_use丢弃(无法执行)
- 明确标注中断(
[interrupted]、reminder 注入)
3.1 为什么不完整 tool_use 不保留
API 契约要求 tool_use 必须配对 tool_result。如果保留一个不完整的 tool_use:
- 没法执行 → 无法生成 tool_result
- 下一轮请求会因配对断裂被拒
- 修复方案是合成 "[interrupted]" 的 tool_result —— 但 tool_use 本身的 input 不完整,连"工具是什么"都未必清楚
所以最干净的处理是丢弃整个不完整 tool_use,用 reminder 在下一轮告诉模型。
3.2 用户 ESC 后的对话延续
用户: 修复登录 bug
我: 让我先查看登录代码...
[streaming 中: "我准备 Read src/auth.t"... ESC]
用户: 等等,先看 src/api 那边
下一轮我看到的上下文:
[messages]
user: 修复登录 bug
assistant: 让我先查看登录代码... [interrupted]
<system-reminder>Your previous turn was interrupted...</system-reminder>
user: 等等,先看 src/api 那边
我能基于这个上下文:
- 知道之前的方向被否决
- 知道用户改了主意
- 不重复 Read src/auth.ts 的尝试
- 转向 src/api
4. 工具执行中的中断
工具已经在跑,用户中断会怎样?
4.1 Bash 工具
def run_bash_with_interrupt(cmd):
proc = subprocess.Popen(cmd, ...)
try:
stdout, stderr = proc.communicate(timeout=cfg.timeout)
return ToolResult(stdout, stderr, proc.returncode)
except KeyboardInterrupt: # 用户 Ctrl+C
proc.terminate()
try:
proc.wait(timeout=2)
except TimeoutExpired:
proc.kill()
return ToolResult(
stdout=proc.stdout.read() if proc.stdout else "",
stderr="[Process interrupted by user]",
exit_code=-1,
)
关键设计:
- 优雅终止优先(SIGTERM)
- 强制 kill 兜底(SIGKILL,2 秒后)
- 保留部分输出(已经写到 stdout 的不丢)
- 明确标记中断状态(exit code -1 + stderr 注释)
4.2 长跑命令
如 npm install、pytest、build 等。这些命令可能跑很久,中断时需要清理:
- 锁文件(package-lock.json 写到一半)
- 临时文件
- 部分写入的输出文件
System Prompt 关于这种情况的应对:
"If you discover unexpected state like unfamiliar files, branches, or configuration, investigate before deleting or overwriting, as it may represent the user's in-progress work. For example, typically resolve merge conflicts rather than discarding changes; similarly, if a lock file exists, investigate what process holds it rather than deleting it."
意思:发现锁文件 / 临时文件 → 不要直接删 → 先调查。这是中断后清理的纪律。
4.3 后台任务的中断
run_in_background=true 启动的任务可以独立运行。中断方式:
TaskStop工具:通过 task_id 主动停止- harness 关闭:所有后台任务被 kill
TaskStop 工具描述:
"Stops a running background task by its ID. Returns a success or failure status."
用户中断主对话不一定会停后台任务。这是设计选择 —— 后台任务是"派出去的活",主对话中断不一定意味着放弃后台工作。
5. AskUserQuestion 期间的中断
我可以用 AskUserQuestion 弹问卷给用户。期间的中断:
我: [发起 AskUserQuestion]
用户: [关闭弹窗 / ESC]
处理:
def handle_question_dismiss(state, question):
# 用户没回答,视为"无答案"
state.messages.append(Message(
role="user",
content=[Block(
type="tool_result",
tool_use_id=question.id,
content="[User dismissed the question without answering]"
)]
))
下一轮我看到 "User dismissed" → 我应该不再追问,而是基于已有信息继续或请用户用自由文字说清需求。
如果我无视 dismiss 反复问 → 用户体验灾难。所以这是一条隐性纪律:dismiss 也是一种回答。
6. 权限提示中的中断
工具调用可能弹权限提示。期间用户可以:
- 批准(once / always)
- 拒绝
- ESC(视为拒绝)
System Prompt 明示:
"If the user denies a tool you call, do not re-attempt the exact same tool call. Instead, think about why the user has denied the tool call and adjust your approach."
关键:被拒后不要立刻重试。要:
- 想"用户为什么拒?"
- 调整方法(换工具、改参数、问用户)
- 或者放弃这条路径
权限拒绝是用户的明确意见表达,强行重试是冒犯。
7. 保持上下文一致性的核心约束
无论何种中断,最终上下文必须满足以下约束才能继续:
1. role 严格交替:user → assistant → user → assistant → ...
2. tool_use 和 tool_result 一一配对,且 tool_result 紧跟对应 tool_use 之后的 user 消息
3. 没有"挂起的"流式 block(要么完整,要么标注中断)
4. cache_control 只放在完整 message 之后
每次中断后的"恢复操作"都要让上下文重新满足这些约束。
7.1 不变量检查工具
def assert_context_invariants(messages):
last_role = None
pending_tool_uses = set()
for msg in messages:
# 1. role 交替
if msg.role == last_role:
raise InvariantError(f"Same role twice at turn {msg.turn_id}")
last_role = msg.role
# 2. tool_use 配对
for blk in msg.content:
if blk.type == "tool_use":
pending_tool_uses.add(blk.id)
elif blk.type == "tool_result":
if blk.tool_use_id not in pending_tool_uses:
raise InvariantError(f"Orphan tool_result at turn {msg.turn_id}")
pending_tool_uses.remove(blk.tool_use_id)
# 3. 不能有未闭合的 tool_use 越过下一轮 user 消息
if msg.role == "user" and pending_tool_uses:
# tool_use 后该有 tool_result,不该直接到下一轮 user
raise InvariantError(f"tool_use unclosed at turn {msg.turn_id}")
# 检查最后状态
if pending_tool_uses and last_role == "assistant":
# 等待 tool_result,正常
pass
每次中断恢复后跑一遍。失败 → 进入修复流程(07 篇 §3.5)。
8. Streaming 期间的 UX 责任
中断处理也涉及给用户的反馈:
| 状态 | UI 应该显示 |
|---|---|
| streaming 中 | 文字逐字出 + ESC 提示 |
| streaming 被 ESC 中断 | "已停止" + 输入框 |
| 工具执行中 | 工具名 + 进度(如果有)+ Ctrl+C 提示 |
| 工具被中断 | "已中断" + 部分输出 |
| 等待权限 | 弹窗 + 选项 |
| 等待 AskUserQuestion | 问卷 + dismiss 提示 |
| 后台任务运行 | 在 statusline 显示 |
UI 状态和上下文状态要严格同步。最容易出 bug 的是 streaming 中断 —— 用户已经看到部分文本,agent 内部上下文必须包含同样的部分文本。否则下一轮模型会"否定"它自己说过的话。
9. 持久化与恢复:crash safety
如果 harness 进程崩溃,重启后能恢复吗?
设计选择:
| 状态 | 持久化方式 | 恢复 |
|---|---|---|
| 消息历史 | 文件(每轮 append) | 完整恢复 |
| 工具执行中 | 不持久化中间态 | 视为中断 |
| 后台任务 | 进程独立 | 进程可能还活着 |
| 权限提示 | 不持久化 | 重启后丢失,需重新发起 |
| AskUserQuestion | 不持久化 | 同上 |
崩溃后的恢复流程:
- 加载最后一次完整保存的消息历史
- 检查不变量
- 修复挂起的 tool_use(合成 [interrupted])
- 重新建立后台任务连接(如果可能)
- 让用户继续对话
这就是为什么 scheduled_tasks.json 等关键状态必须持久化(参见主文档关于 CronCreate 的 durable 字段)。
10. 取消传播
中断 / 取消应该传播多远?
用户 ESC
└→ 当前 streaming 停止 ✓
└→ 当前同步工具执行停止 ✓
└→ 后台任务? ✗(默认不停)
└→ 已发起的子 agent? 看场景
└→ 已写入文件的修改? 不撤销(除非用户明确要求)
设计权衡:
- 全部传播 → 用户能完全撤回,但可能丢失有用的并行工作
- 不传播 → 后台工作继续,但可能产生用户没意识到的副作用
我的策略是默认不传播,但 UI 显示状态:
- 后台任务在 statusline 提醒
- 用户要 stop 必须显式("stop the background task")
这把决策权留给用户,而不是 harness 替用户决定。
11. 部分状态的可恢复性
不是所有"半完成"都坏。有些可以继续而不是重启:
| 状态 | 可继续吗 |
|---|---|
| streaming 文本中断 | 否(API 不支持续写) |
| 工具 Bash 中断 | 否(重新跑可能不幂等) |
| 后台 agent 暂停 | 是(用 SendMessage 唤醒) |
| AskUserQuestion 已回答但 agent 没读到 | 是(重新读 result) |
| 长跑工具支持 checkpoint | 是(重新调用从 checkpoint 续) |
设计 agent 时,让长跑操作支持 checkpoint 是值得的投资。比如批量处理 1000 个文件的工具,每 100 个保存一次进度,中断后能从 800 续。
12. 给 Agent 设计者的可迁移规则
- streaming 是默认:用户体验差异巨大,值得做
- 中断点要明确:哪些是安全的、哪些是不安全的(writing critical state)
- 不变量检查机制化:每次状态变更后跑
- 不完整 tool_use 不保留:丢弃 + reminder
- dismiss 也是回答:不要无视用户关闭弹窗
- 权限拒绝不重试:System Prompt 明文要求
- 取消传播谨慎:默认局部停,全局停要用户显式
- 持久化关键状态:crash 后能恢复
- UI 状态与上下文状态同步:用户看到的就是 agent 看到的
- 长跑操作支持 checkpoint:投资值得
13. 一句话总结
Streaming 让用户和 agent 的体感对齐,中断让用户保持控制。但二者都创造"半完成"状态 —— 上下文一致性的维护责任全在 harness。把"什么算安全中断点、中断后怎么收尾、上下文怎么恢复一致"这三件事想清楚,agent 才能在真实人机交互中存活。
下一篇:12 · 多模态上下文