尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

qwen-code 的 ACP-over-HTTP 可断点续传会话流:基于 SSE `Last-Event-ID` 的事件重放设计

qwen-code 的 ACP-over-HTTP 可断点续传会话流:基于 SSE `Last-Event-ID` 的事件重放设计 qwen-code 的 ACP-over-HTTP 可断点续传会话流基于 SSELast-Event-ID的事件重放设计【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code导读本篇技术指南围绕 sse-resumable-stream.md 展开剖析 qwen-code 守护进程qwen serve如何为 ACP-over-HTTPStreamable HTTP传输层补上会话事件流的断点续传resumable能力。文章以该设计文档为主体骨架结合仓库中的 EventBus 环形缓冲区、SSE 流写入器、连接注册表与 TypeScript SDK 实现讲解Last-Event-ID游标如何在重连时驱动环形缓冲重放、会话流分离-宽限期-回收机制如何让重放真正生效以及重放正确性的两个关键守卫。读完你可以完整理解/acp会话事件流从只支持实时live-only到支持断点续传的底层原理、实现变更与边界限制并掌握--event-ring-size等运维参数的实际作用。一、问题背景实时会话流的断档丢失ACP-over-HTTP 传输层的会话事件流由GET /acp携带Acp-Session-Id请求头承载在设计补丁之前它是**纯实时live-only**的既不在 SSE 帧中输出id:序号也不在重连时读取客户端的Last-Event-ID请求头。当控制面的代理ingress proxy在对话中途空闲关闭这条长连接时守护进程本身会发送retry: 3000而代理又频繁掐断长 SSE 连接客户端会重连并重新声明会话所有权但守护进程在断档期间产生的所有内容帧都会丢失——这些帧通常是携带agent_thought_chunk/agent_message_chunk的session/update通知。最终这一轮对话仍然会走到终止状态turn_complete会被产出或被合成于是 UI 显示已完成但正文却是空的或被截断的。重新发送同样的提示词可以工作这正是关键线索丢的是传输断档而不是模型输出。设计文档将这一症状与现场证据记录在集成笔记的 §1.8sdk-known-issues.md中。与此同时文档明确区分了两个相邻问题§1.7会话流上丢失的进行中的 prompt 响应JSON-RPC 响应不在事件环中属于另一个跟踪项§1.8丢失的内容帧即全部经由 bus 的session/update事件——这正是本次补丁要解决的对象。二、已具备的重放引擎EventBus 环形缓冲区这个补丁之所以小而巧是因为重放引擎早已建成并被充分验证缺口仅仅是/acp传输层没有接上它。引擎位于 packages/acp-bridge/src/eventBus.ts单调递增的事件 id每个会话一个单调id从 1 开始在publish()中分配nextId。事件必须先通过可序列化门禁DAEMON-011才会占用 id被拒绝的事件不会烧掉序号避免其他订阅者看到 3 → 5 这样的空洞有界环形缓冲区每个会话一个环默认深度DEFAULT_RING_SIZE 8000源码注释解释了从 1000 上调到 8000 的原因单次长 prompt 可能产生数百帧真实负载可达数倍于此运维可用qwen serve --event-ring-size n覆盖带游标订阅subscribeEvents(sessionId, { lastEventId, signal })会在实时事件流入之前先重放环内id lastEventId的帧并发出若干合成控制帧replay_complete——重放排空信号无论是否有帧被重放都会发出让消费者确定性结束追赶中状态state_resync_required——环已被驱逐ring evicted/ 守护进程重启导致 epoch 重置epoch reset/ 重放字节预算超限时发出提示客户端状态已不可信、需调用 loadSession 恢复client_evicted、slow_client_warning——慢客户端背压治理帧。注意合成控制帧client_evicted、state_resync_required、replay_complete等不携带id因此不会在会话单调序列中占据槽位——否则其他健康订阅者会在实时流和续传时看到空洞破坏连续性。REST 表面早已消费这套机制GET /session/:id/events读取last-event-id头server.ts→parseLastEventId传给subscribeEvents并用formatSseFrame为每帧序列化出 SSEid:行。而/acp传输层dispatch.ts的pumpSessionEvents、sse-stream.ts的SseStream.send在补丁前全部缺席sse-stream.ts源码注释里甚至明说no ring-bufferid:sequencing — resumability is RFD Phase 4, deferred。下表对比了两条表面的差异层次REST/session/:id/events/acpGET补丁前读取Last-Event-ID请求头是否将lastEventId传给subscribeEvents是否dispatch.ts pumpSessionEvents输出 SSEid:行是formatSseFrame否SseStream.send只写data:三、线上决策SSEid:行而非 payload 内_meta两条 SSE 表面承载的载荷形态不同这决定了续传游标的放置位置REST流传输BridgeEvent信封{ id, v, type, data, _meta }SDK 解析器sdk-typescript/src/daemon/sse.ts从 JSON 信封的id字段提取游标只读data:行/acp流传输的是裸 JSON-RPC 2.0 对象session/update通知、session/request_permission请求、响应等它们没有承载 bus 游标的信封id——而且 JSON-RPC 的id语义是请求 id不能挪作他用。因此/acp的续传游标采用标准 SSEid:行理由充分EventSource 原生符合规范的 SSE 客户端包括随仓库 vendored 的AcpHttpTransport会自动记录最后一条id:并在重连时自动回填Last-Event-ID请求头无需自定义逻辑载荷纯净JSON-RPC 协议帧内不注入非标准的_meta.qwen.eventId与 REST 对齐formatSseFrame在 REST 上输出的就是同样的id:行因此两条表面共享同一套eventBus id 与同一套Last-Event-ID语义。需要明确的是只有 bus 来源的帧携带id:session/update、session/request_permission、守护进程推送的通知。在会话流上搭车的JSON-RPC 响应/回复不是 bus 事件不携带id:——它们不在环内、刻意不被重放跟踪丢失的进行中 prompt 响应是 §1.7 单独跟踪的问题。合成的终止帧client_evicted、stream_error等没有 bus id同样不输出id:行避免在客户端续传的单调序列中烧掉槽位。四、实现变更清单从总线到线上的一整套打通设计文档列出 6 项核心变更全部集中在packages/cli/src/serve/acp-http/transport-stream.tssend(message, id?: number)。可选的id即 bus 事件 id用于 SSE 游标跟踪。该文件定义了TransportStream接口与DeliveryResultdelivered | outcome_unknown | closed | failedsse-stream.tssend(message, id?)在id ! undefined时于data:行前追加id: ${id}\n镜像 RESTformatSseFrame。实现细节帧按id:→data:→ 载荷 → 空行 的顺序写入所有写入经单一writeChain串行化心跳注释不能插队并遵守背压res.write返回 false 时等待drain流自身拥有写失败处理——首次失败记日志并关闭杜绝僵尸流ws-stream.tssend(message, id?)接受并忽略id——WebSocket 是有状态连接无 SSE 重放与AcpWsTransport.supportsReplay false一致且不能把 SSE 的id:框架泄漏进 WS 裸 JSON 帧connection-registry.tssendSession(sessionId, frame, id?)把id透传给传输层。会话级 pre-attach缓冲区改为存储一个已序列化的 UTF-8 载荷 其可选游标 预算租约PreparedFrame这样被缓冲的帧保留游标、又不必持有源对象或在 attach 时重复序列化。连接级回复复用同一表示dispatch.tstranslateEvent为 bus 事件把event.id透传给每次sendSession/binding.stream.send调用pumpSessionEvents(conn, sessionId, signal, lastEventId?)将lastEventId转发给subscribeEvents——直接复用既有环形重放index.tsGET /acp会话流分支读取Last-Event-ID请求头经由严格版parseLastEventId与 REST 同为仅接受十进制数字规则传给pumpSessionEvents。关键点是eventBus/bridge 零改动——引擎被原样复用。parseLastEventId被抽取为共享模块 packages/cli/src/serve/sse-last-event-id.tsREST 与/acp两条表面共用同一套严格接受/拒绝规则与运维日志不会漂移。该模块还提供parseEventEpochHeaderX-Qwen-Event-Epoch请求头用于 DAEMON-001 的过期 epoch 检测。五、让续传真正生效会话流的分离 宽限期 回收id:/Last-Event-ID的管道打通是必要但不充分的——仅靠它在真实流程中永远不会触发。原因在于此前当会话 SSE 流在传输层关闭时GET 处理器会执行完整的closeSessionStream拆卸流程从ownedSessions移除会话、abort 进行中的 prompt、分离 bridge 客户端。而在真实的 EventSource/代理时序中旧 socket 先关闭客户端后重连携带Last-Event-ID的重连会在游标被读取之前就被所有权检查以403拒绝——而且正在产出内容的 prompt 已经被 abort 了重放引擎根本没有东西可以重连。因此补丁把传输层会话流关闭从拆卸改为分离detachAcpConnection.detachSessionStream只停止流本身 其事件订阅保留 binding、所有权、进行中的 prompt、bridge-client 注册持续一个宽限期SESSION_GRACE_MS镜像连接级CONN_GRACE_MS二者在 index.ts 中均为10_000即 10 秒宽限期内重连即回收reclaimattachSessionStream清除宽限定时器环形重放回填断档若无人重连宽限定时器执行完整拆卸——约束失控 prompt 的成本显式session/close与连接拆卸destroy仍然立即完整拆卸GET 处理器依据stream.isClosed分支传输层关闭 → 分离 宽限pump 结束而流仍打开子进程结束 / 迭代器错误→ 完整关闭僵尸流。实现细节值得注意attachSessionStream遵循先安装新流再关闭旧流的顺序且旧流 pump 的收尾逻辑以binding.stream做身份守卫identity-guard——只有自己仍然是该会话的活跃流时才执行操作。这样旧流在关闭时落入分离 宽限而非拆卸进行中的 prompt 得以存活这正是第 18 轮评审 G1 修复的回归问题重连曾无条件 abort 进行中的 prompt。六、两个重放正确性守卫宽限期/回收机制让重放路径首次可达两个潜在的正确性隐患也随之暴露因此随本补丁一同发布守卫一既无重复投递、也无静默丢失缓冲区 ↔ 环被缓冲的 bus 事件同时也在 EventBus 环内它正是为了拿 id 才发布进去的。因此在续传存在Last-Event-ID时attachSessionStream拿到游标后完全不冲刷携带 id 的缓冲帧——从客户端游标开始的环形重放成为游标之后所有 bus 事件的唯一投递路径。这是刻意设计帧已发送给现已死亡的 socket 但客户端从未收到时其 id 低于缓冲区的 id 却高于客户端的游标——如果先冲刷缓冲区、再把重放游标推进过缓冲区就会静默丢弃这帧。让环接管所有 bus 事件每个事件恰好投递一次、无缺口。而无 id 帧经replySession路由的 JSON-RPC 回复不是环事件环不会重投——但 attach 时也不能冲刷若在重放前冲刷缓冲的session/prompt结果它会跑到其前置内容块之前客户端先看到done再看到正文——正是 §1.8 要修的截断正文故障。因此在续传时无 id 帧被延迟留在缓冲区由事件 pump 在重放排空后仅在replay_complete时通过flushBufferedSessionFrames释放保持原始流顺序。关键约束绝不能挂在state_resync_required上冲刷——EventBus 在重放帧之前就发出该帧随后仍会在末尾发出replay_complete若在此冲刷会把回复放到被重放内容之前。纯实时场景无Last-Event-ID⇒ 无重放 ⇒ 无replay_complete由 pump 的循环后安全冲刷兜底全新连接无Last-Event-ID没有环锚点立即按序冲刷整个缓冲区与补丁前行为一致。相关实现可见 connection-registry.ts 的sendSessionReply带anchorId水印的延迟投递、releaseDeferredSessionRepliespump 每投递一个内容事件后按水印释放、endReplayDeferralreplay_complete边界与flushBufferedSessionFrames无条件最终冲刷。守卫二重放下幂等的permission_requestpermission_request是携带 id 的环事件因此游标位于尚未答复的 permission之前的重连会重放它。补丁后的translateEvent复用该bridgeRequestId在conn.pending中的既有条目对追赶中的同一条出站 JSON-RPC id 重发而不是新铸第二个 id 条目——不会产生孤儿 pending也不会让按_meta.requestId去重的客户端收到双重 prompt。七、向后兼容性补丁对三类消费者都保持兼容旧客户端不发送Last-Event-ID→lastEventId为undefined→subscribeEvents从实时开始行为与今天完全一致新增id:行是向后兼容的 SSE——忽略该字段的客户端不受影响基于 EventSource 的客户端自动开始跟踪它并在重连时回填vendored SDKAcpHttpTransport在本补丁中显式启用重放源码中readonly supportsReplay truepackages/sdk-typescript/src/daemon/AcpHttpTransport.ts重连时回填Last-Event-ID请求头断档帧从环中重放§1.8 内容丢失无需守护进程进一步改动即被闭合。任何仍上报supportsReplay false且省略该头的消费者守护进程侧改动保持惰性。外部agent-web传输的开关翻转不在本仓库范围见下文。此外预附加队列被计数与字节双重约束单个流最多持有 256 帧单个逻辑连接最多 1024 帧 / 64 MiB全部 ACP HTTP 挂载共享进程级 4096 帧 / 256 MiB 预算。溢出时关闭精确所有者session 或逻辑连接而不是驱逐更旧的帧续传丢弃携带 id 的缓冲事件时会释放其保留的字节租约。REST 表面完全不受影响。八、测试计划从单测到端到端设计文档的测试计划覆盖四个文件其中三个可在此仓库直接找到sse-stream.test.tssend(msg, 7)在data:前输出id: 7\nsend(msg)无 id省略id:行顺序为id:→data:→ 空行transport.test.ts端到端经/acp传输层实时session/update帧现在携带id:行携带Last-Event-ID: N的GET /acp把游标流入subscribeEvents无头的新流行为与今天一致溢出的Last-Event-IDMAX_SAFE_INTEGER→ 退化为纯实时真实先关后连顺序先关闭旧 SSE再用Last-Event-ID重连——断言200 而非 403所有权被保留且 prompt未被 abort宽限/回收被重放的permission_request复用 pending 条目相同出站 idconnection-registry.test.ts非续传 attach 冲刷整个缓冲区并逐个线程化id续传attach有游标跳过携带 id 的帧环重放接管但仍冲刷无 id 的 JSON-RPC 回复detachSessionStream在宽限期内保留所有权/prompt、到期后拆卸宽限期内重连即回收取消待执行的拆卸ws-stream.test.tssend(msg, id)忽略 id——WS 线上帧是裸 JSON无 SSEid:框架泄漏。九、明确不在本次范围仍延后设计文档如实记录了以下边界避免读者误以为本补丁覆盖了全部续传场景WebSocket / HTTP/2 传输不携带 SSEid:/Last-Event-ID语义另行处理§1.7 跨连接 permission resolve投票 POST 到与流式 prompt 不同的Acp-Connection-Id上的问题是独立且涉及安全敏感性的后续项。本补丁只让permission_request翻译在重放下幂等不新增会话级 requestId resolve已决议 permission 的响应重放幂等将已决议结果记录在会话级有界 LRU 以重发记录票也归入同一后续会话流上丢失的进行中 prompt 响应恢复的内容帧都走 eventBus 环JSON-RPC 响应不是环事件外部agent-webAcpHttpTransport的supportsReplay翻转位于不同仓库本 PR 已解阻经导出的 SDK 传输进行 permission 投票导出传输把session/request_permission暴露为permission_request事件但 SDK 的投票 APIrespondToPermission/respondToSessionPermission映射到一个 ACP 守护进程没有处理器的session/permission请求——守护进程只接受以 JSON-RPC响应回显出站_qwen_perm_Nid形式的投票。同时无订阅者的会话回复泵ensureSessionReplyPump会打开真实GET /acp会话流守护进程将其视为活跃流导致仅回复泵挂载时触发的permission_request被路由到该流并被泵丢弃泵只转发 JSON-RPC 响应使 mediator 挂起。需要 permission 处理的消费者应按文档契约先打开subscribeEvents再发起会话 RPC在导出AcpHttpTransport的subscribeEvents循环内发起会话 RPC会话/acp流是单读器消费端异步生成器在两次yield之间停驻时读取器不排水在事件处理循环内await会话路由 RPCsession/set_model、session/prompt等会挂起直到消费者拉取下一个事件。修复方向是把会话读取器改为始终排水 JSON-RPC 回复的后台泵仅将DaemonEvent入队给迭代器SESSION_STREAM_REPLY_METHODS⇄replySession漂移的自动化守卫SDK 的该方法集合必须镜像dispatch.ts中的replySession(...)调用点不同包任何一侧遗漏都会导致无回复泵的sendRequest挂起至 abort。正确的提取器需要轻量数据流分析session/prompt的回复不在其case块内发出而是由 prompt 完成处理器在异步完成后从不同调用点触发长期修复方向是让守护进程主动宣告会话路由方法名作为共享真源。十、小结从只实时到可续传的完整链路至此/acp会话事件流的续传链路可以一句话概括GET /acp读取Last-Event-ID→pumpSessionEvents传给subscribeEvents→ EventBus 环按游标重放断档帧 →SseStream.send为每帧附加id:行 → 传输层断连时分离而非拆卸10 秒宽限期→ 重连回收并续传。补丁的本质是接线而非发明重放引擎环形缓冲、游标订阅、合成控制帧早已在 packages/acp-bridge/src/eventBus.ts 中完备存在并被 REST 表面验证本补丁把id:序号、Last-Event-ID解析、缓冲帧游标透传、分离-宽限-回收生命周期逐一接入/acp并用两个正确性守卫缓冲↔环单次投递、permission 重放幂等封堵了重放路径上的潜在缺陷。对于熟悉 SSE 断点续传或想在自己的 ACP 客户端中利用Last-Event-ID恢复断档内容的开发者这份设计与配套源码sse-stream.ts、connection-registry.ts、dispatch.ts、sse-last-event-id.ts提供了完整的参考实现与测试基线。【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表