Skip to content

第 13 章 交互闭环:确认、提问、中断 ​

本章代码 code/ch13/(main.py / ask_user.py / fake_model.py / 前端四件套) · 读完约 24 分钟

13.0 上一章留下的问题 ​

第 12 章的界面已经能把一轮回复渲染完整。但它有一个说不出口的缺陷:它只能看,不能答。

后端在第 11 章就会发 REQUIRE_USER_CONFIRM 了,前端收到的处理是一行提示:

js
text: `需要确认:${names}(第 13 章接上按钮)`

于是权限系统辛苦拦下来的危险操作,在界面上变成一句没有下文的话。另有两件事根本没有通道:

  • 模型想提问。 "你要删的是这三个文件里的哪一个?"这类话,模型只能写进正文里,然后这一轮就结束了,用户下一句再从零开始
  • 用户想叫停。 唯一的办法是关掉标签页。而后端那个 asyncio 任务并不知道你走了,它会把这一轮跑完,把工具调完,把 token 烧完

这一章把这三件事补齐。它们看起来是三件事,在协议层面其实是两类:确认和提问共用同一条通道,中断走另一条。

动作谁发起协议服务端要做的
确认权限引擎REQUIRE_USER_CONFIRM → POST /api/confirm回填 ConfirmResult,续跑
提问工具(借权限通道)同上把答案塞进参数,续跑
中断用户POST /api/interrupt分两种情况:发事件,或取消任务

13.1 确认:三种回执,不是一个布尔值 ​

第 11 章的 /api/confirm 只接受 confirmed: true | false。这个接口能跑,但它把三种不同的意图压成了一个:

  • 这次允许,下次还问
  • 允许,并且以后同类操作别再问了
  • 拒绝

第二种是产品体验的分水岭。一个会反复问同一件事的助手,用户第三次就会关掉确认功能,然后所有保护一起失效。

所以请求体改成带 decision 的形式:

python
class ConfirmRequest(BaseModel):
    session_id: str
    tool_call_id: str
    decision: str = "allow"              # allow / always / deny
    input: dict[str, Any] | None = None  # 用户改过的参数

always 的实现只有五行,用的是第 7 章的规则容器:

python
rules: list[PermissionRule] = []
if body.decision == "always":
    rule = PermissionRule(
        tool_name=target.name,
        rule_content="",                 # 空字符串 = 匹配这个工具的所有调用
        behavior=PermissionBehavior.ALLOW,
        source="user",
    )
    PermissionEngine(session.agent.state.permission_context).add_rule(rule)
    rules.append(rule)

两个细节。

规则要放进 ConfirmResult 里一起交回去。 你也可以直接往 permission_context 里写,效果一样,因为规则本来就是存在那里的。但框架提供 ConfirmResult.rules 这个字段,是为了让"这次确认附带产生了一条规则"成为一次原子操作——回执和规则同生同死,恢复会话时不会出现"回执还在、规则丢了"的中间态。

rule_content 是空字符串。 第 7 章讲过它的语义随工具而变:Bash 是子串匹配,Read / Write 是 glob。空字符串的意思是"这个工具的所有调用都符合"。用户点"一直允许"时想要的就是这个粒度。

回执本身必须带完整的 tool_call,不是只带一个 id:

python
result = ConfirmResult(
    confirmed=body.decision != "deny",
    tool_call=ToolCallBlock(
        type="tool_call",
        id=target.id,
        name=target.name,
        input=json.dumps(args, ensure_ascii=False),
    ),
    rules=rules,
)

input 是 JSON 字符串。这个坑第 10 章提过一次:ToolCallBlock.input 的类型是 str,不是 dict。

确认弹窗

参数被摊开显示,而不是藏在一个"查看详情"的折叠里。理由很简单:用户要评估的是这次操作的具体内容,不是工具名。 看到 Write 三个字就点允许的人,和在看到 notes.md 这个路径之后才点允许的人,承担的风险完全不同。

三个按钮的权重也不一样:主色给了"允许一次","一直允许"是次要按钮。默认选项应该是那个不容易后悔的。

点下"允许一次"之后,框架重新进入暂停的那一轮,把 ConfirmResult 当作外部执行结果交回去,工具真正落盘,模型继续说完剩下的半句话:

确认之后,工具真的执行了

上面这一轮是接真模型(deepseek-v4.1-flash)跑的,两个数字值得留意。输入 1679 token、输出 357 token,模型调用 2 次——第一次是生成工具调用,第二次是拿到工具结果后组织回复。第二轮调用的输入包含了第一轮的全部上下文和工具输出,所以它比第一轮大得多,这也是长会话里成本增长的主要来源(第 14 章会给它记账)。

另一处细节在工具卡片上:file_path 是一整条绝对路径,不是 ./sandbox/notes.md。内置的 Write 只接受绝对路径,写相对路径会被直接拒绝。这不是本章的知识点,但凡是把 Write 交给模型的章节都要注意——提示词里必须给出绝对目录,否则模型会理所当然地拼一个相对路径出来。

13.2 提问:借一条现成的通道 ​

从 2.0.9 开始,agentscope 提供了官方的 AskUser 工具和终端交互组件。本书的基线是 2.0.5,所以这里给一个不依赖高版本的写法——而且这个写法本身很值得学,因为它解释了框架里"人机协同"的最小骨架。

框架里能让 Agent 停下来的机制只有一个:权限系统判定 ASK。那就借它。

python
class AskUser(ToolBase):
    def __init__(self) -> None:
        super().__init__()
        self.name = "ask_user"
        self.description = (
            "当信息不足、或需要在几个方案之间做选择时,向用户提问并等待回答。"
            "把可能的选项写进 options,用户点击比你猜要快得多。"
            "不要在能自己判断的时候调用它——提问会中断当前一轮。"
        )
        self.input_schema = AskUserInput.model_json_schema()
        self.is_read_only = False      # 见 13.7 错误 1
        self.is_concurrency_safe = False

    async def check_permissions(self, tool_input, context) -> PermissionDecision:
        return PermissionDecision(
            behavior=PermissionBehavior.ASK,
            message="需要用户回答后才能继续",
            bypass_immune=True,
        )

    async def call(self, **kwargs) -> AsyncGenerator[ToolChunk, None]:
        answer = str(kwargs.get("answer") or "").strip()
        ...
        yield ToolChunk(
            content=[TextBlock(text=f"用户对「{args.question}」的回答:{answer}")],
            state="success",
            is_last=True,
        )

is_read_only = False 和 bypass_immune=True 是两处必须写的防御,原因都在第 7 章的判定顺序里:

  • 只读工具走"只读放行"快路径,check_permissions() 根本不会被调用。一个声明只读的 ask_user 永远不会问,它会直接执行,然后因为拿不到答案而返回错误。这一条是实测出来的,见 13.7
  • bypass_immune 保证即使用户在这个会话里选过"全部放行",提问依然会送到他面前。提问不是安全检查,它不能被批量放行

选择题弹窗

13.2.1 答案是怎么回去的 ​

这是全章最巧的一处。ConfirmResult 里带回来的 tool_call 会覆盖原始参数。框架源码里的注释写得很直白:

python
# Update name and  input in case user modification is allowed
tool_call.name = confirmation.tool_call.name
tool_call.input = confirmation.tool_call.input

也就是说,"用户在确认时修改参数"是官方支持的行为。那么提问就可以这样实现:

  1. 工具被调用时,模型只填 question 和 options
  2. 前端把题目渲染成按钮
  3. 用户点了一个,前端把这个选项写进 input.answer,连同 decision: "allow" 一起提交
  4. 后端把 answer 合并进原始参数,回填给框架
  5. 框架执行工具,call() 拿到 answer,把它作为工具结果交回模型

实测的工具入参:

[工具收到的参数] {'question': '这三张截图先拍哪一张?',
                'options': ['确认弹窗', '选择题弹窗', '中断后的界面'],
                'answer': '确认弹窗'}

模型看到的下一条消息是 用户对「这三张截图先拍哪一张?」的回答:确认弹窗,然后继续推理。

这个设计还有一个副作用值得知道:answer 不在 input_schema 里。 模型永远看不到这个字段,也就不会尝试自己填它。schema 是给模型看的说明书,不是参数白名单——多出来的键会原样传到 call()。

13.3 中断:两种卡点,两条路 ​

"中断"这个词在实现里对应两种完全不同的状态,混淆它们是本章最容易犯的错。

此刻的状态runningtask正确做法错误做法的后果
正在生成 / 正在跑工具True活着task.cancel()—
停在确认,等你点击False已结束UserInterruptEventcancel() 无效,什么也不会发生

第二种状态很反直觉:停在确认时,那一轮的 asyncio 任务其实已经结束了。 回复的生成过程退出了,只是有一个工具调用被标记成 ASKING,等着外部输入。所以取消任务无从下手。

python
async def interrupt(self) -> str:
    if self.confirm_pending is not None:
        pending, self.confirm_pending = self.confirm_pending, None
        await self.start_resume(UserInterruptEvent(reply_id=pending.reply_id))
        return "已放弃本次确认"

    if self.task is not None and not self.task.done():
        self.task.cancel()
        return "已取消本次回复"

    return "当前没有进行中的任务"

两条路的收尾都是同一个结果,只是事件序列不同。

取消任务时,框架捕获 CancelledError,补一个 finished_reason 为 interrupted 的 REPLY_END 再退出,随后 _run 的 finally 照常发 done:

event: TEXT_BLOCK_DELTA      ← 已经吐出去的字收不回来
event: TEXT_BLOCK_DELTA
event: REPLY_END
data: {"finished_reason": "interrupted"}
event: done

停在确认时发中断事件,框架会把待确认的调用补成一条"被中断"的工具结果,再收尾:

event: TOOL_RESULT_START
event: TOOL_RESULT_TEXT_DELTA
event: TOOL_RESULT_END
data: {"tool_call_id": "write_1", "state": "interrupted"}
event: REPLY_END
data: {"finished_reason": "interrupted"}
event: done

13.3.1 中断之后,上下文是一致的 ​

这是必须验证的一件事。半句话被留在了上下文里,下一次对话模型看到的是一个没有下文的助手消息——它会当成事实继续。

实测:取消之后紧接着再问一句,context 从 2 条增长到 4 条,第二轮正常完成,finished_reason 是 completed。原因在 Agent 内部:中断处理会把未完成的工具调用关闭(state=interrupted),保证消息列表里不出现"有 tool_call 没有 tool_result"的残缺配对。一旦出现这种残缺,下一次模型调用会直接报校验错误。

中断后的界面

被中断的回复保留已经生成的部分,末尾插一条说明。不要把它整段删掉——用户看到的内容、上下文里的内容、账单上的内容应该是同一份。删掉界面上的半句话,只会让人以为那部分 token 没有花。

13.4 前端增量 ​

界面这一章加了三样东西,都不大。

一个弹窗,两种长相。 pending.name === "ask_user" 时渲染题面与选项;否则渲染参数与三个按钮。共用一个遮罩层,共用一个 sendConfirm()。

一个停止按钮。 只在生成中出现。它调用 /api/interrupt,不关心后端走哪条路。

把用户消息也编成一帧。 这一条是被截图逼出来的。第 12 章的前端在本地插入用户那条消息,服务端的事件流里只有助手侧的内容。后果是:一旦要支持"打开历史会话"(?session=xxx 回放缓冲区),画面里就只剩助手在自言自语。

python
async def start_reply(self, text: str) -> None:
    self._emit("USER_MESSAGE", {"session_id": self.session_id, "text": text})
    await super().start_reply(text)

前端收到这一帧时默认插一条,但如果本地已经插过同样的内容就跳过——本地先渲染是为了点击后立刻有反馈,不能为了这条帧让用户等一个网络往返。

顺带说明 ?session=xxx 这个入口为什么值得留:事件都在服务端的环形缓冲区里,带上 id 打开就能完整回放。调试、截图、给同事发一个"你看这个问题"的链接,都靠它。

13.5 四个常见错误 ​

错误 1:ask_user 声明成只读,永远不会提问。

python
self.is_read_only = True     # 错

现象:界面上没有弹窗,工具直接执行,返回"用户没有作答"。日志里 REQUIRE_USER_CONFIRM 一次都没出现。

原因:第 7 章的判定顺序是 deny 规则 → ask 规则 → 只读放行 → 工具自检 → allow 规则 → 模式默认。只读放行在工具自检之前,check_permissions() 没有被调用过。

对策:is_read_only = False。语义上它确实不写任何东西,但"只读"在这个框架里不只是描述,它是一个会改变判定路径的开关。

错误 2:check_permissions 签名写成 **kwargs。

python
async def check_permissions(self, **kwargs) -> PermissionDecision:   # 错

现象:TypeError: AskUser.check_permissions() takes 1 positional argument but 3 were given。

原因:引擎是按位置调用的 tool.check_permissions(tool_input, context),位置参数进不了 **kwargs。

对策:写成 async def check_permissions(self, tool_input, context)。只读工具不会撞上这个错,因为它们的这个方法从来不会被调用——所以这个错误常常在工具改成非只读的那一刻才暴露。

错误 3:done 之后不重新连流,第二轮没有反应。

现象:第一轮对话正常,第二轮发出去之后界面一片空白,右上角一直"连接中"。

原因:done 到达时后端关闭订阅队列,这条流就作废了。如果 send() 只 POST /api/chat 而没有重新 connect(),第二轮的帧就会在服务端缓冲区里堆着,没人来取。第 12 章的 send() 末尾已经调用了 connect(),所以它能连续对话;自己实现时别漏这一步。

对策:每一次发起请求(发消息、提交确认、请求中断)之后都调一次 connect(),用 last_event_id 续传。同时要加一个 connecting 标志去重,否则连点两次发送会开两条流,两个读循环抢同一批帧,表现出来是气泡里的文字重复。

错误 4:打开历史会话,只出现第一轮。

现象:一轮一轮聊完,刷新页面带上 ?session=xxx 回放,界面上只剩第一问第一答,事件计数停在十几。没有报错,没有红色告警,后端日志里 /api/stream 返回 200。

原因:第 12.3 节那个"done 来了就抛异常退出读循环"的写法在实时对话里看不出问题,因为一轮只有一个 done。回放时服务端把整个缓冲区补发一遍,而缓冲区里每轮回复都有一个 done,碰到第一个就退出,后面几轮的帧全部被丢弃。

对策:不要在 done 上做跳转,把它当成普通帧交给 reducer,让服务端关连接来结束这次读取。这个坑的代价在于它不产生任何错误信息,只表现为"少了一段内容",很容易被误判成后端没存下来——所以排查这类问题时,先数一下服务端缓冲区里到底有多少帧,再去怀疑前端。

13.6 小结 ​

三件事,两个通道,一套收尾:

  1. 确认是三种回执。always 生成一条空 rule_content 的 allow 规则,随回执一起交回
  2. 提问借用权限通道。ASK 让本轮停下,答案通过"用户可修改参数"这个官方行为回填进工具入参
  3. 中断分两条路。跑着就取消任务,停在确认就发 UserInterruptEvent;两条路的终点都是 finished_reason=interrupted,上下文都保持完整
  4. 每轮结束后要重连——后端在 done 时关流,这是 SSE 单轮模型必然的代价

到这里,产品在功能上是完整的:能对话、能干活、能被管住、能被叫停、能中途反问你。

但还有一件事没有答案:它花了多少钱,出了什么问题,你从哪里知道? 现在所有信息都在内存里——重启进程,用量归零,日志消失。下一章做可观测:把用量落盘,把结构化日志写出来,让"昨天那次为什么慢"这个问题有答案。

教程正文采用 CC BY-NC-SA 4.0 许可,code/ 下示例代码可自由使用