跳转至

第 9 章 交互面与会话管理

同一个 harness,命令行、编辑器与服务端三种形态的用户体验差别很大,但它们背后的状态机应当是同一套。交互面的职责是把内部状态投影成人能理解的反馈,并把人的介入翻译成状态迁移。本章给出呈现、中断、审批、进度、会话与错误呈现的判据。

1. 交互面是状态机的投影

界面上的每个状态都应能对应到一个后端事件:正在采样、正在执行工具、等待审批、已完成、已失败。界面自己发明状态(例如用动画假装"正在思考"),会让用户在不存在的阶段等待,也会掩盖真正的卡点。

投影层做三件事:归并(同一工具的连续调用合成一张卡片并计次)、限频(token 增量按 30–60 ms 合并一帧,避免每条 delta 触发一次重排)、定优先级(等待审批 > 正在执行 > 已完成,低优先级事件不覆盖高优先级状态)。事件与呈现的对应关系可以固化成一张表:

后端事件 界面状态 可用操作
run.started 已开始 停止
model.delta 生成中 停止、转向
tool.started / tool.finished 执行中(工具名、耗时) 停止(不可回滚)
approval.requested 等待审批(影响面) 允许、拒绝、改参数
run.finished 完成或失败(分类) 查看轨迹、重跑

判据:任意时刻,用户都能回答三个问题——它在做什么、做了多久、是否需要我。答不上来,说明投影缺了这一维,而不是用户不够耐心。

2. 流式呈现与工具可见性

文本可以逐段呈现,工具执行不能只显示一个转圈:工具往往是耗时主体,用户需要知道调用了什么、参数是什么、返回了什么。呈现上分三层:会话级(任务列表与预算余额)、回合级(文字流与工具卡片)、条目级(参数与输出的完整内容)。默认停在回合级,条目级按需展开。

工具参数是增量拼接的,未闭合的 JSON 不是可执行参数。参数流式显示要标"生成中",闭合并通过 schema 校验后才显示为最终值;否则用户会把半截参数当成将要执行的命令。输出被截断时标明省略量与获取完整结果的方式(分页游标、落地文件路径),不能让用户以为返回内容只有这么长。凭据、内网地址一类敏感参数按策略脱敏,脱敏发生在投影层,不依赖前端自觉。

不要把原始 JSON 直接铺在界面上,也不要把工具输出整段刷屏。判据:摘要足以判断"是否需要展开",展开后足以判断"结果是否可信"。

3. 中断与转向

中断有两种语义,必须分开:停止生成(不再产生新的 token)与回滚执行(撤销已发生的副作用)。前者随时可做,后者只对可撤销动作成立;对不可逆动作,中断的含义是"停在下一个安全点",而不是"当作没发生"。

取消是协作式的:外部只设置取消标志,循环在每次模型返回、每次工具返回、每次审批返回之后检查标志。直接杀进程会把"已发出但未确认"的工具调用留在未知状态——请求可能已经到达外部世界,只是本地没收到回执。因此不可逆调用发出前先落一条 tool.dispatched 事件,中断后按这条事件对账,而不是假设它没发生。

用户在生成过程中的输入是转向(steering):把新要求并入下一轮,而不是丢弃当前结果重来。实现上要区分"排队等待当前轮结束"与"抢占当前轮";抢占需要保留已生效状态,否则一次转向会把已完成的工作清零。排队的语义是"当前轮做完再处理",抢占的语义是"在当前工具返回处截断并合并新约束";后者的代价是当前轮的思考要作废,只保留副作用记录。

判据:中断后的界面要显示"哪些已生效、哪些被取消、下一步是什么",不能只显示"已停止"。

4. 审批交互

审批要防两类失败:一是弹窗太多导致疲劳,用户闭眼点允许;二是信息太少导致盲批,用户不知道自己在批准什么。对策是批量处理同类请求、记住参数模式、展示影响面(改哪些文件、动哪些关节、发往哪里)、允许拒绝并附原因。

一条审批请求至少携带六项:动作名、目标对象、影响面、可撤销性、有效期、替代方案。影响面要落到具体范围而不是工具名——git push 的影响面是"仓库 X 的分支 Y,将新增 3 个提交",不是"执行 git push"。批量授权的范围绑定参数模式:记住"对本仓库当前分支放行",而不是"对所有 git push 放行";工作目录、分支、权限等级任一变化即失效。

默认值按风险取:只读操作可自动放行,写操作逐次确认,不可逆操作必须显式批准。超时应默认拒绝,而不是默认通过;"没人在"不等于"可以执行"。被拒绝的请求要把拒绝原因回传给模型,否则下一轮它会换一种写法重试同一条动作。

判据:审批记录要能回答"谁在何时批准了什么",并进入轨迹(第 8 章)。

5. 进度、任务列表与长任务

长任务需要把计划外化成可见列表:已完成、进行中、待执行、被阻塞。列表来自 harness 的真实状态(第 6 章的检查点),不是前端估算的百分比。进度条可以平滑,但底层必须由事件驱动。

进度按事件溯源维护:每次状态变更写一条 task.updated(任务 id、旧状态、新状态、原因),当前视图由事件重放得出。总量可测时才显示百分比(如 12 个文件处理完 5 个),总量不可测时显示阶段名与已用时——对开放式任务做线性外推得到的 ETA 只会训练用户不信任进度。被阻塞的任务必须带阻塞原因与解除条件("等待审批:写入 /etc","等待外部服务恢复"),否则列表只是把"卡住"换个说法。

判据:任务列表在进程重启后仍能恢复;只存在前端内存里的进度,一次断线就归零,用户无法判断该重跑还是该等待。

6. 会话生命周期

会话是可序列化的状态:消息历史、工具结果引用、预算余额、权限决定、检查点。新建、恢复、分叉、压缩都围绕这份状态做。分叉复制状态但隔离写入,适合"从同一节点试两条路";恢复按检查点重建,不重放全部历史。

序列化格式要带 schema_version:恢复时先校验版本,再做字段迁移,版本不认识就拒绝加载而不是猜字段含义。工具结果按内容寻址存放(sha256 或 tool_call_id 引用),消息里只留引用与摘要——既避免同一份大输出在压缩后重复出现,也保证两个会话引用同一结果时内容一致。审批决定与预算余额属于会话状态,换客户端继续时必须一并带过去,否则同一任务在新窗口里会重新要授权、重新计费。

并发要明确:同一会话被两个客户端打开时,是只读观察、还是允许同时写?默认应拒绝并发写,或按消息序加锁;把并发留给不同会话。写锁的粒度按轮次而不是按键,避免两个端各写半轮导致历史交错。

7. 错误呈现与责任边界

错误要分类呈现:模型侧(选错工具、参数无效)、工具侧(报错、超时)、权限侧(被拒)、环境侧(服务不可用)。每类给不同的下一步:换参数、重试、申请授权、稍后再试。把四类都显示成"出错了",用户只能反复重试。

错误对象至少带四个字段:类别、可重试性(可重试 / 需改参数 / 需授权 / 不可恢复)、已生效副作用、建议动作。可重试性由错误来源决定,不由错误文案决定:连接超时可以退避重试,参数校验失败重试多少次都一样,权限拒绝重试只会继续被拒,不可逆动作已执行一半则既不能重试也不能当作未发生。

责任边界要写清:系统做了什么、用户授权了什么、哪些结果是部分完成。部分完成时给出"继续"与"回滚"两条路,回滚只对可撤销动作出现。隐藏部分完成会让用户误以为需要重跑,重复执行不可逆动作。

8. 三种形态的差异

命令行以键盘与流式文本为主,适合快速迭代与脚本化;编辑器以文件上下文与内联差异为主,适合就地修改;服务端以无状态请求与回调为主,适合集成到他处。三者共用状态机,差异在投影与介入方式。

同一条安全门在三种形态下的投影不同,语义相同:命令行用交互式确认(--yes 只能跳过"询问类",不能跳过"拒绝类"),编辑器把影响面渲染成内联差异让人逐块接受,服务端必须提供审批回调端点与超时策略——请求方拿到的是 pending 票据,而不是默认放行。服务端形态还多一条约束:审批人与发起人可以是不同主体,票据里要记录二者,否则审计只能追到服务账号。三种形态的审批票据应当是同一份数据结构,只换展示层;各形态各写一套,审计就会漏记其中一路。

9. 要点

  • 交互面是状态机的投影,界面状态必须有后端事件对应。
  • 用户随时能回答:在做什么、做了多久、是否需要介入。
  • 工具执行要可见:摘要一行,详情可展开。
  • 中断分停止生成与回滚执行;不可逆动作停在安全点。
  • 审批防疲劳也防盲批;超时默认拒绝,记录进轨迹。
  • 进度来自真实事件与检查点,不靠前端估算。
  • 会话可序列化;分叉隔离写入,并发写默认拒绝。
  • 错误分类给下一步;安全语义不因形态而变。

参考文献与延伸阅读

  • Anthropic. Tool Use 文档. 文档
  • OpenAI. Function Calling 指南. 文档
  • Model Context Protocol. 规范与文档. 规范