40-agent-chat-streaming-interrupt. Agent Chat 打字机平滑 + 打断-排队 + 串台加固
日期: 2026-08-30 状态: 📝 需求文档,讨论中,尚未实现 触发: 用户反馈"前端刷新不及时、没有打字机效果",怀疑要不要把传输层从 HTTP 换成 WebSocket 来解决打字机效果、多 Agent/多聊天切换、执行中无法打断这几个问题。经排查现状代码 + 对照 hermes-webui、 hermes-agent、 deepseek-harness 三个参考实现, 结论是传输层不是瓶颈,不需要引入 WebSocket——三个参考项目全部走 SSE/HTTP 流式, 没有一个用 WebSocket。真正的差距是另外三件独立的事,本文档逐一给方案。
起因
现状排查发现:LemonBoss 的 Agent Chat 已经是 SSE 流式,不是普通 HTTP 一问一答——
web/src/views/AgentChat.vue:941 用 fetch().body.getReader() 逐行解析
event:/data:,后端 server/routes/agent-chat.js:2542(POST /:agentId/chat)用
setupSSE(res) 把 Claude CLI 子进程吐出的 ACP agent_message_chunk 事件实时转发给前端。
多聊天"串台"(切换 Agent 时旧流写串到新 Agent 状态里)已经在 9b1c3f1 修复:每个 Agent
一份独立的 chatStates reactive 状态 + requestAgentId 快照,事件处理函数(handleEvent,
AgentChat.vue:991)全程用 isCurrent() 校验"这条流是不是还对应当前选中的 Agent"。
对照 hermes-webui(api/gateway_chat.py 的 _stream_writeback_is_current)和
deepseek-harness(interruptByParent:中止当前 turn → 新消息进 FIFO → 排队执行),
确认这套"per-stream id + is-current 校验"就是业界通用解法,LemonBoss 已经用对了模式,
不需要换传输层来重新解决一遍。
真正剩下的三个独立问题:
- 打字机不流畅——不是传输问题,是 ACP chunk 粒度太粗(Claude CLI 一次吐一大段,不是 逐字/逐 token),SSE 本身能把这段文本秒级送达前端,但前端拿到手就是一大坨,没有"逐字吐出" 的观感。
- 执行中无法打断并接上新消息——现状是"先 Stop 再发"两步操作,且发送框在
chatState.sending为真时整个被disabled(AgentChat.vue:192),物理上打不了字。 deepseek-harness 有专门的打断-排队状态机,LemonBoss 没有。 - 串台已修,但没有回归测试兜底——
9b1c3f1是一次性修复,isCurrent()校验散落在handleEvent十几个分支里,没有测试盯着,将来改动容易在某个分支漏掉校验又退回去。
一、现状代码地图(后续设计都基于这份)
| 组件 | 位置 | 现状行为 |
|---|---|---|
| 前端发送/接收 | AgentChat.vue:908-969(handleSend) |
fetch() 开流,st.sending=true 期间 textarea disabled(:192),流结束才 sending=false |
| 前端事件分发 | AgentChat.vue:991-1042(handleEvent) |
按 requestAgentId === agentId.value 做 isCurrent() 校验,非当前 Agent 的流只落状态不刷 UI |
| 后端 SSE 入口 | agent-chat.js:2542(POST /:agentId/chat) |
每次请求 while(loopCount<10) 循环喂 prompt 给 ACP 协议,onUpdate 把 agent_message_chunk 原样透传成一条 SSE agent_output 事件 |
| 后端并发闸门 | agent-chat.js:2552-2568(agentChatLocks,键是 agentId) |
同一 Agent、不同 convId 并发请求直接 429 拒绝;同一 Agent、相同 convId 的并发请求不会被这段拒绝——第二个请求会直接覆盖锁往下跑,等于两个循环同时喂同一个 ACP 会话。目前靠前端 disabled 挡住,服务端没有闸门(见下文风险①) |
| 停止端点 | agent-chat.js:1505(POST /:agentId/chat/:convId/stop) |
调用 closeSession(key, reason)(agent-chat.js:840),即 entry.protocol.disconnect() 杀掉常驻 CLI 子进程,是"硬中止",不接排队 |
| 锁释放 | agent-chat.js:2880(finally 块) |
请求 handler 退出时统一 agentChatLocks.delete(agentId) + res.end();中止后原请求是否会立刻走到这里,取决于 protocol.disconnect() 是否让 handler 内部 await 的 ACP 调用抛错——需要在实现阶段用真实 CLI 验证一次(见风险②) |
二、方案一:打字机平滑(两端都做)
根因:ACP agent_message_chunk 的粒度由 Claude CLI 决定,一次可能带几十到几百字符,
SSE 转发是逐 chunk 转发(agent-chat.js:2628 sendSSE('agent_output', ...)),前端收到
就是这一整段直接拼进 st.streamContent,没有二次拆分,所以观感是"一坨一坨蹦出来"而不是
打字机。
2.1 后端:转发前按字符数切片
在 agent-chat.js:2624 的 onUpdate 里,把 update.content.text 按固定步长(比如 8-12
字符一片)拆成多条 sendSSE('agent_output', ...),而不是一次性整段发送。这是唯一需要碰
后端的点,改动局限在这一个回调函数内,不影响日志落盘(logMessageChunk 仍然整段写,只是
发给前端的部分被拆片)。
2.2 前端:渲染节流缓冲
在 handleEvent 收到 agent_output 时(AgentChat.vue:1015),不直接把整段 data.message
拼进 st.streamContent,改成推进一个逐字符渲染队列,用 requestAnimationFrame 或
setInterval 按固定速率(比如 30-50 字符/秒)从队列取字符追加显示。这样即使后端某次真的
一次性吐了一大段(比如网络抖动攒批),前端也能把它"抹平"成匀速的打字机效果,不依赖后端切片
的粒度是否稳定。
两端都做的原因:后端切片解决"数据到达的最小单位太粗",前端缓冲解决"到达节奏不均匀"(网络 延迟、CLI 输出突发)——只做一端在极端情况下(比如后端切了片但网络一次性把好几个 SSE 事件 一起送到)还是会看起来一卡一卡。
三、方案二:打断-排队状态机
3.1 交互设计
保留现有 Stop 按钮(AgentChat.vue:193,⏹)不变——纯中止,点了就是杀会话,不接后续。
新增:执行中解锁输入框,允许在 chatState.sending === true 时继续打字。此时发送按钮
的语义从"发送"变成"打断并发送",两个操作在视觉上要能一眼区分开——用户分不清点了哪个,
后果是"以为消息接上了,其实这轮直接被丢掉":
- Stop 按钮(
AgentChat.vue:193,⏹):图标/文案不变,补一句 tooltip「仅停止,不发送 新消息」。 - 发送按钮在 sending 态下:换一个视觉上明显不同的图标/配色(暖色调,区别于平时发送用的 主色),tooltip 改为「发送并打断当前回复」。
- 打断成功后,在对话记录里插入一条不打断阅读的分隔标记(细线 + 小字,类似 "— 已打断上一条回复 —"),让用户回看聊天记录时能看懂"上一条为什么没说完"——这是 ChatGPT / Claude.ai 自己在"stop and continue"场景下的通用做法。
点击"打断并发送"后的具体流程:
- 前端把发送按钮切到"打断中…"状态,锁住重复点击(防止手速快连点两次触发两轮打断);
- 调用现有
stop端点(复用agent-chat.js:1505,不新增中止路由,但要改内部实现,见 3.1.1); - 根据
stop返回的cleaned字段决定下一步(见 3.1.1),确认可以安全发送后,把这条新 消息按正常发送流程投出去,带同一个 convId,接续同一个对话历史,并在发送成功后 插入上面说的分隔标记。
这本质是"打断并立即顶替",但工程实现上做成一个深度为 1 的排队(先确认上一个 turn 真的 已经退出,再发新的),不是前端拿到点击事件就立刻并发发第二个请求——原因见风险①: 服务端目前对"同一 convId 并发"没有闸门,真并发会导致两个循环同时写同一个 ACP 会话。
3.1.1 stop 端点改造:后端确认清理完成,而不是前端猜时间
现状 stop 端点(agent-chat.js:1505-1512)调用 closeSession 后立刻同步返回,不代表
原来那个 /chat 请求的 handler 已经真正退出(风险②)。改造成:
stop端点内部在调用closeSession之后,轮询等待agentChatLocks里对应agentId的锁被清掉(即原请求走到agent-chat.js:2880的finally),预算 3 秒(closeSession杀子进程本身是毫秒级,慢的是原请求await被异常唤醒到走完finally这段,3 秒是给日志 flush 这类边缘情况留的余量,正常路径应该用不到)。- 3 秒内确认清理完成 → 返回
{ success: true, cleaned: true },前端立刻发新消息,用户基本 感觉不到延迟。 - 3 秒仍未确认(极端情况) → 返回
{ success: true, cleaned: false }(尽力而为,不算失败), 前端不自动强发(会撞回风险①的并发洞),而是在输入框附近给一条不遮挡输入的提示 「上一条还在收尾…」+ 一个「强制发送」按钮,把"要不要冒这个小概率风险"的决定权交还给用户, 而不是系统默默赌一把。
3.2 需要新增/改动的点
| 位置 | 改动 |
|---|---|
AgentChat.vue:192 |
textarea 的 :disabled="chatState.sending" 改成允许 sending 态下继续输入;autoResize/handleKeydown 不受影响 |
AgentChat.vue:194 |
发送按钮增加"sending 态下点击 = 打断并发送"分支,UI 上给一个过渡态(如按钮文案/图标切换) |
AgentChat.vue:handleSend(:908) |
抽出"等待上一个 turn 收尾"的等待逻辑,供打断分支复用 |
agent-chat.js:2552-2568(agentChatLocks) |
补上同 convId 并发闸门(风险①,见下)——无论是否做打断功能,这都是一个已经存在、只是暂时没被前端触发到的并发 bug,建议顺带修 |
agent-chat.js:1505(stop 端点) |
改成内部轮询确认清理完成再返回,加 cleaned: true/false 字段,见 3.1.1 |
3.3 风险 / 需要在实现阶段验证的点
- 同 convId 并发无闸门(现存 bug,非本次引入):
agent-chat.js:2554的判断是existingLock.convId !== convId才拒绝,同 convId 的第二个请求会直接覆盖锁往下跑。 目前靠前端disabled属性挡住,从没被服务端真正堵过。一旦解锁输入框,这条路径变得 可达,必须先补服务端闸门,否则用户手速快一点就能让两个循环同时喂同一个 ACP 会话(未定义 行为,轻则消息乱序,重则 CLI 子进程状态损坏)。 - 中止后原请求是否会及时退出,还未验证:
closeSession只是protocol.disconnect(), 原来那个还挂着await的POST /chat请求 handler 会不会因此立刻抛错走到finally(:2880)释放锁、结束响应,取决于ClaudeCliProtocol底层对子进程异常退出的 处理方式。已拍板的应对方案见 3.1.1(stop端点内部轮询 + 3 秒预算 +cleaned字段- 前端超时兜底按钮);实现阶段仍需要用一次真实 Claude CLI 会话验证「中止后原请求平均/最坏 多久会真正退出」,如果实测远超 3 秒(比如需要等底层网络超时),要回来调整这个预算数字, 而不是改设计本身。
四、方案三:串台加固(回归测试)
9b1c3f1 的修复方式是在十几个 handleEvent 分支里散落地加 if (isCurrent) ...,容易在
未来新增分支时漏掉。建议补一个前端组件级测试(而不是走完整 E2E),思路:
- 模拟两个 Agent 的
chatStates,对 Agent A 发起一次"慢"流(用假的 SSE 事件序列,中间插 延时),在流还没结束时把agentId.value切到 Agent B; - 断言 Agent A 后续到达的事件(
thinking/agent_output/tool_call等每一种 SSE 事件类型都要覆盖到)只更新了chatStates.get('A'),不影响当前显示(Agent B 的视图); - 反向断言:切回 Agent A 时,之前"憋住"没刷 UI 的内容应该已经正确落在
chatStates.get('A')里,切回来就能看到完整历史,不丢消息。
这条测试的价值不是证明现在没 bug(9b1c3f1 已经手工验证过),而是卡住以后的回归——
后续如果给 handleEvent 加新的 SSE 事件类型,忘了包 isCurrent() 判断,测试会红。
五、不做的事(明确排除)
- 不引入 WebSocket。三个参考项目和 LemonBoss 现状都证明 SSE 足够,问题不在传输层。 真要引入 WebSocket 的场景是"没有聊天窗口打开时也要收到 Agent 的后台推送"(比如心跳异常 主动弹通知),这是一个不同的需求,不属于本文档范围,需要的话应该另开文档讨论。
- 不做"多条消息同时排队"的完整 FIFO。deepseek-harness 那套是为多 Agent parent-child 场景设计的,LemonBoss 这里是单用户单会话打断,深度 1("打断当前的,接上 新的")已经覆盖需求,做完整队列是过度设计。
六、实施顺序建议
三个方案彼此独立,互不阻塞,建议顺序(从风险低/收益快到风险高):
- 方案三(串台回归测试)——纯新增测试,零风险,先把安全网铺好。
- 方案一(打字机平滑)——只碰渲染逻辑和一个转发回调,不改并发/状态机,风险最低。
- 方案二(打断-排队)——涉及并发闸门(风险①必须先修)和状态机改造,且风险②需要真实 CLI 验证,建议放最后,且实现前先用一次真实 Claude CLI 会话验证"中止后原请求多久会退出"。
七、已拍板的设计决策
以下两点原本是未决问题,已确认,写在这里避免以后重新讨论一遍:
- 超时策略:由后端
stop端点内部确认清理完成(轮询agentChatLocks),预算 3 秒; 超时不代表失败,返回cleaned: false,前端不自动强发,展示「上一条还在收尾…」+ 手动 「强制发送」按钮,风险由用户显式承担,不做静默重试。详见 3.1.1。 - UI 区分:Stop 按钮和"sending 态下的发送按钮"必须视觉上可区分(图标/配色 + 不同 tooltip),打断成功后在对话记录里插入一条分隔标记,便于事后回看。详见 3.1。
本文档目前没有遗留的未决问题;进入实现阶段后如果冒出新的(比如风险②实测的具体数字要调整 预算),回来更新这一节。