ARTICLE · 1177455
DeepSeek-Harness 源码深读(24):HTTP 响应里一个字都没有,回答从日志里流出来
摘要:浏览器提交 Prompt 时只发出一次
POST /api/session.prompt,响应是{ accepted: true }。流式回答、工具卡和运行状态不走 HTTP 响应,而是先写入会话日志,再从两条只读 WebSocket 返回。断线后也不续传 WebSocket:重开流、重拉尾页,按seq把历史和实时事件缝起来。
连接已经建立。你在输入框里敲下一句:
跑一下这个仓库的测试,把失败原因总结给我。
回车。
接下来几秒,浏览器只向 host 发出一次 HTTP POST。响应体里没有模型生成的文字,回答从另一条管道回来,而那条管道的源头不是“往 UI 推消息”,是会话日志。
我们跟着这句 Prompt 走一遍,中途再拔一次网线。
一、回车先过输入状态机
InputBar 不会把 Enter 直接映射成发送。它先拦住 Shift+Enter 和输入法合成期的 Enter(InputBar.tsx:360-364):
if (e.key === 'Enter' && e.shiftKey) return// keyCode 229 is the legacy IME-composition signal engines emit without isComposing.const composing = composingRef.current || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229229 这个魔法数字是在兜中文输入法。部分浏览器在选词阶段不会可靠地设置 isComposing,但会发出 keyCode 229。少了这层判断,确认候选词的 Enter 可能顺手把半句话发出去。
守卫通过后,resolveSubmitMode 才决定这次提交是普通排队,还是向正在运行的会话插话(InputBar.tsx:431-435):
keyboard.submit(resolveSubmitMode( running, accelerated ? 'accelerated' : 'enter', subagent === null,))算出的 queue 或 steer 会一路传到 host。提交动作先进入 InputMachine,由状态机处理草稿、撤销栈和 chip 占位符,再把发送 effect 交给 InputHub。
输入框此时已经乐观清空。请求失败,草稿才恢复,前提是用户没有继续修改。UI 在这里撒了一个善意的谎:回车那一下看起来零延迟。
Session.prompt 还有一个更窄的时序处理。它在第一个 await 前同步设置 promptAttempted,让页面赶在导航后的第一帧从 blank 进入 engaging。放到 await 后面,状态切换就可能晚一帧。
二、上行只有一张 HTTP 票根
请求进入 WebApiClient.callUnary。客户端生成 rpcId,把 Prompt 包成 client-request,发往:
POST /api/session.prompt响应回来后再核对 rpcId。请求与响应的配对靠编号,不靠返回顺序。
host 端用 RpcMethodMap 约束路由表:session.prompt 对应自己的 schema 和 api.sessions.prompt,少一项、贴错 schema 都会在编译期暴露。RPC 方法名就是 HTTP 路径,没有再套一层 REST 资源模型。
请求到达 ApiProxy.sessions.prompt 后还要过准入。时区先被规范化,非法 IANA 名直接拒绝;turnAgentFor 再查找当前 Agent。进程重启后没有活 Agent,这里会按 session 日志冷恢复。Prompt 带图时,还要检查模型输入模态,并把临时图片提升为持久引用。
准入完成后只剩两个出口(api-proxy.ts:2425-2428):
const durable = await durablePromptContent(ctx, content)const message: UserMessage = createUserMessage({ content: durable, source })if (mode === 'steer') agent.steer(message)else agent.followup(message)steer 进入当前 turn 的下一步,followup 等待下一轮。然后 HTTP 返回:
{ 「accepted」: true }就这些。字呢?
三、回答先写日志,再交给 UI
followup 和 steer 最终都进入 Agent inbox,区别只是目标槽位:next-turn 或 next-step。driver 被唤醒后,turn/start、user/message 和 step/start 依次进入 session。
模型开始流式输出时,每个 chunk 先写日志(agent.ts:348-352):
for await (const chunk of stream) { signal.throwIfAborted() chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq) assembler.push(chunk)}顺序别看反了。先 append 拿到 seq,再交给 assembler。浏览器里的打字机效果,每一下背后都是一条已经进入日志的 durable 事件。
真正的枢纽是 session.append(core/session/index.ts:627-643,摘核心):
const event = deepFreeze({ type, seq: this.log.length, time: Date.now(), data: dataSnapshot, // …} as unknown as SessionEvent<T>)this.surfaceManager.validateNext(event as SessionEvent)// …this.log.push(event as SessionEvent)完整流程还会同步调用 session/event 回调。深冻结、分配 seq、写入 log、派发回调之间没有 await。事件刚进入日志,订阅者就能拿到它;系统没有另外维护一份“已经推送到哪儿”的状态。
日志是事实源,UI 只是它的投影。
四、下行为什么拆成两条只读流
host 的 events.mux 订阅 session/event。新事件出现后,它被包装成 frame,进入当前 WebSocket 消费者自己的 FrameQueue。工具调用还会经过 viewFor,由 host 算出 ToolEventView,浏览器只负责画工具卡。
Web 端同时打开两条流:
events.mux:session 事件、队列、审批、提问和投影;events.host:session 增删、running 状态、Agent 错误和 workspace 变化。
两条连接都只允许下行。浏览器一旦向其中任何一条发送消息,host 立刻关闭连接(websocket-downlink.ts:109-111):
websocket.once('message', () => { websocket.close(1008, 'downlink only')})上行统一走一元 HTTP RPC,下行统一走事件流。只收到 assistant/chunk 不够,页面还要知道 session 何时从 running 变回 idle;只有 host 状态也不够,聊天窗口不会长出回答。两条流承担的是两类变化。
五、两个 onOpen 仍然不算 connected
ConnectionController 为每次连接创建一个 generation,并行打开 events.mux 和 events.host。两条 WebSocket 都触发 onOpen 后,还要再完成一次 host.describe(connection.ts:139-142):
const [description] = await Promise.all([ this.api.host.describe({}), Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]),])两条物理流打开,加上一元 RPC 可达,三者齐了才触发 onConnected,流打开有 3 秒超时。
这个顺序是在保护重连后的 resync。mux 打开时会先发送 session/subscribed 基线,其中带着 lastSeq;客户端连上后又会重拉历史。如果 resync 跑到基线前面,历史窗口与实时事件可能错位。
任意一条流断开,整个 generation 都作废。重试从 500ms 开始指数增长,10s 封顶,并在 cap/2 到 cap 之间加入随机抖动。host 重启时,多个标签页不会同时回潮。
六、断线不续传,重新读日志
现在我们拔掉网线。
浏览器先清掉可作答的审批、提问及对应缓冲帧。这个动作不能等到下次 connected:新 mux 一打开,host 会重放仍然挂起的审批;旧状态清晚一步,可能把刚回来的新帧一起抹掉。
连接恢复后,每个已打开的 session 各自执行 resync(),从日志尾部往回重拉 history 窗口,每页 50 条。麻烦在于,WebSocket 实时事件可能比 history 响应更早到。
acceptLiveEvent 负责缝合(session.ts:688-701):
private acceptLiveEvent(event: SessionEvent, view?: ToolEventView): void { if (this.openState === 'loading' || this.stitching) { this.liveBuffer.push({ event, view }) return } if (this.openState !== 'open') return // cold/error: no window upkeep (history fully backfills on open) const tailSeq = this.windowTailSeq() if (tailSeq !== null && event.seq > tailSeq + 1) { this.liveBuffer.push({ event, view }) void this.repairGap() return } this.scheduleConversation(this.appendLive(event, view))}历史还在加载,实时事件先进入 liveBuffer;发现 seq 跳号,也先缓冲,并触发 repairGap() 补尾页;只有编号连续才直接落进窗口。安装窗口时,seq 是唯一的去重键,history 和实时缓冲谁先到都不重要。
events.mux 预留了 since,但 v1 没有实现(api/events.ts:53 的注释写明 reconnection 就是重开流)。恢复不靠 WebSocket 续传,也没有服务端重传缓冲,就是重开流、重拉日志、按 seq 缝合。
七、chunk 逐条落盘,页面按帧更新
一次长回答可能产生几百甚至上千个 chunk。host 侧每个 chunk 都要经过快照校验、深冻结、日志追加和 WebSocket 序列化;浏览器如果每来一条就触发 React 更新,主线程会忙于重复绘制。
ConversationNodeAssembler 给事件定义了三档发布节奏(conversation-nodes/assistant.ts:279-284):
publication: (match) => { if (match.event.type === 'step/start') return 'none' if (match.event.type !== 'assistant/chunk') return 'immediate' const type = match.event.data.chunk.type return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame'},usage 和 finish 不单独触发可见更新;普通文本 chunk 合并到下一动画帧;工具卡出现、最终消息落地等结构变化立即发布。浏览器用 requestAnimationFrame 合并刷新,没有 RAF 的测试环境退回微任务。
存储和传输都没有合批。合批的只有渲染。一千个 chunk 仍是一千条日志和一千条 WS frame。RAF 救的是浏览器主线程,磁盘和网络该付的代价一点也没省。
八、这套设计省了什么,又付了什么
我们把账摊开看。省下的是传输层的可靠状态。系统不需要维护确认序号、重传缓冲,也不用让服务端和浏览器同步一份消息折叠结果。多开一个标签页,每页从自己的 subscribed 基线开始接收即可。
代价也很具体。每个 chunk 都要做 JSON 快照校验、深冻结、日志追加和 WS 序列化;session 越长,重拉窗口的成本越高。渲染侧可以合并,存储和下行流量没有消失。
还有一道部署边界不能漏掉。/api 入口的 isTrustedApiRequest 防的是 DNS 重绑定,不是用户认证。默认监听 loopback 没问题;端口暴露到局域网后,同网段设备仍可能调用 session.prompt,而后面的 Agent 有机会执行本机工具。
九、答案从来不在 HTTP 里
网线插回。两条 WebSocket 和 host.describe 重新就绪,session/subscribed 带着 lastSeq 到达,history 尾页被拉回,先到的实时 chunk 暂存在 liveBuffer,随后按 seq 并入窗口。
假设基线是 lastSeq: 4817,你最终看到的那句话,可能是第 4818 行之后的一串 assistant/chunk。浏览器把这些连续事件折叠成一条回答。
它从头到尾没有存在于 HTTP 响应体里。
下一篇把视角拉高:这条链路里反复出现的 provider、seam 和 adapter,到底在隔离哪些变化。
本系列基于 DeepSeek Harness 源码(MIT,0.1.1-rc.1)与官方 Agent Notes 整理,仓库:github.com/deepseek-ai/deepseek-harness。有收获就点个关注,下一篇见。















































































