DeepSeek Harness 源码解析(二):一次用户请求如何经过整个 dsh 系统

沿固定版本的源码追踪一次 dsh 请求:从浏览器提交、Host 校验和 Agent 入队,到模型调用、工具执行、会话日志及界面更新,配合可暂停和单步推进的数据流演示。

Hex4C59 16 分钟阅读 更新于 2026-10-17

本文依据 DeepSeek Harness 0.2.1-alpha.2 的源码解析请求链路。实现分析依据源码和已有测试定义;动画采用教学时序与演示数据。本轮没有运行 dsh 行为测试或真实模型请求。

从“发送”按钮开始 #

在开篇里,我们把 Harness 理解为模型周围组织上下文、工具、状态和执行过程的软件层。这个定义还比较抽象。要理解 dsh 的职责,可以沿着一条用户消息走一遍:谁接收它,谁把它放进模型请求,模型提出工具调用以后又由谁执行?

本文面向了解 HTTP、Git 和大语言模型基本概念的开发者,阅读代码时会用到少量 TypeScript。主场景是已经启动的 Web 应用:用户选择一个普通 Session,以 queue 模式提交非空文本,模型路由已经配置,也就是系统已知道应把请求交给哪个模型服务。Session 是 dsh 保存一段会话记录的对象;这里的 queue 表示先把输入排队,等后续一轮执行(Turn)开始时再取出处理。

我们用一个假设任务贯穿全文:“读取 README.md,并概述这个项目。” 这个例子选择原生 read 工具路径,未启用程序化工具调用(Programmatic Tool Calling,PTC)。图里的文件内容与事件序号都是演示数据,用来观察模块之间传递什么。附件、子 Agent 和不同 preset 的组合留给对应篇章。

先看完整链路 #

用户在浏览器发送消息后,服务器先检查输入,再把消息交给负责这段会话的 Agent 排队处理。开始执行时,Agent 取出待处理的内容,准备模型请求,并接收陆续返回的结果。模型若提出工具调用,系统可以在获准后执行工具,把结果放入下一次请求。一次工作可以包含多次这样的请求。与此同时,会话记录和实时输出通过另一条订阅连接送回浏览器。

这里有三条同时值得关注的路径。

路径承担的工作不同于另外两条的地方
输入上行浏览器 → 会话接口 → Host → Agent Inboxaccepted 确认输入已接收
核心执行Agent → 模型 → 工具 → 下一 Step一条用户输入可能触发多次模型调用
结果下行实时帧、会话事件和状态 → 客户端浏览器持续接收更新,不等待提交接口返回整段回答

先沿着这三条路径读下去:前半篇追踪输入如何进入模型,后半篇追踪工具结果与浏览器更新。在“工具结果怎样进入下一次请求”一节,可以逐步播放工具反馈回路,并对照消息历史与事件载荷观察数据变化。

查看静态模块图

dsh 模块关系:浏览器通过 Host 提交输入,Agent 调用模型与工具,日志和实时帧通过订阅回到浏览器。

输入之前,应用已经完成组合 #

“消息进入 dsh”有两个不同的起点。应用启动时要选择 Profile,也就是要运行的应用配置,再按配置加载插件、把服务登记到运行时,并准备模型和会话能力。应用已经运行后,用户每次发送消息主要经过会话接口与 Agent 循环。把启动和提交画在同一条顺序线上,会让人误以为每条消息都重新建立整个运行时。

CLI 的 runCli() 识别调用类型,Profile 路径进入 runProfile(),根据 bundle、profile patch 和用户覆盖装配插件。Web 和 Headless 由不同组合提供外围入口,后面的核心 Agent 循环可以共享。

Web Host 的 Agent 控制器会复用已经存活的 Agent,或恢复所选 Session。解析和恢复还涉及身份与所有权检查。本文从运行中的普通会话开始,因此不把创建会话、选择模型或切换 preset 混入最短输入路径。

浏览器提交的是会话请求 #

本地回显先于接收确认 #

用户点击发送时,客户端首先要更新自己的界面。默认输入由 InputHub 路由到 conversation.sendSession()。它建立带 requestId 的本地提交回显,保留草稿与附件的对应关系,给浏览器一次绘制机会,再组织提交内容。

因此,用户看到消息出现在界面里,只能说明客户端已经建立本地提交状态。网络、Host 校验和 Agent 接收仍可能失败。requestId 把本地提交与 Host 侧接收关联起来,后面的去重也会使用它。

Session.prompt() 带上 sessionId、requestId、mode、content 和客户端时区,调用普通 Session 的远程方法。子 Agent 使用另一条命名空间,并有不同的附件规则,不能套用普通会话的全部细节。

Gateway 把远程调用送到 Host 方法 #

远程过程调用(Remote Procedure Call,RPC)让客户端以调用方法的方式请求 Host 服务。客户端 Gateway 根据生成的接口描述准备参数,通过 connection.rpc.call('/api', endpoint, ...) 发出调用。

Host Gateway 查找接口描述、校验参数,并确定这次调用应使用哪个服务实例。它再通过携带本次调用信息的服务对象执行对应方法,让方法能取得本次远程调用的上下文信息。这个 RPC 是客户端与 dsh Host 的通信,模型 provider 的网络请求还在后面。

物理连接、重连与逻辑流协议另有实现。本篇先区分它们的位置:RPC 送达会话控制器,模型请求由 Agent 后续驱动。

Host 接收消息,但回答还没开始 #

Host 的 prompt() 检查空白输入和显式客户端时区,解析普通 Agent,再检查 requestId 是否已经存在。已在 Inbox 或会话日志中的输入可以直接返回 { accepted: true },避免重复追加同一条提交。

带附件时还要检查模型图像能力、文件 receipt 和对应存储。前端选中了文件,并不能代替 Host 对附件的校验。这里的纯文本例子省略这些分支。

校验通过后,Host 构造带 source.kind = 'user' 和 rpcId 的用户消息。普通排队模式调用 agent.followup(),steer 模式调用 agent.steer()。最后返回的 accepted 表示提交被接收;模型内容、任务结束原因和磁盘写入结果都不放在这个确认里。

接收确认和驱动唤醒没有“客户端必须先收到确认,Agent 才能执行”的先后约束:followup() 已经在 Host 的同步调用路径中唤醒驱动,确认的网络返回可以与后续执行交错。

Inbox 里的输入何时进入模型历史 #

入队事件已经属于会话日志 #

followup() 将消息送往 next-turn 并请求唤醒;steer() 面向 next-step 并唤醒;inject() 也面向 next-step,但自身不触发唤醒。

这三个入口体现了输入消费的时机差别。排队消息等待新的 Turn;引导输入或工具附加上下文可以进入后续 Step。它们仍要经过队列和准备流程,才会成为一次模型调用看到的内容。

ReactLoopInbox.mutate() 同步追加 agent/inbox/spliced。会话根据这条事件更新当前队列,这种从事件算出当前状态的过程称为“投影”;系统还会另外发出实时 agent/inbox/inserted 通知。这里的 agent/inbox/spliced 是通过 Session 提交、供持久化保存的事件。 不能仅凭 agent/ 前缀把所有事件都归为临时通知;至于记录何时真正写到磁盘,后文会单独说明。

Inbox 事件携带了输入,却还不是模型对话中的 user/message。理解这一区别,才能解释“Host 接收成功,但模型请求尚未发出”的状态。

Turn 和 Step 分别界定什么 #

在本文的场景里,Turn 是消费一次排队输入后展开的执行轮次;Step 是其中的一次模型请求阶段及后续工具处理。同一 Step 中还可能存在失败后的请求重试,所以 Step 编号也不能直接当成 provider 调用次数。

负责推进执行的代码先记录 turn/start,再进入 preStep()。它取走下一 Step 的待处理输入,这个动作在源码中称为 claim;在新 Turn 开始时,还会消费一条排队输入。随后它组装 Prompt,也就是准备发给模型的提示内容,再进入 agent/pre-step waterfall。Waterfall 指一组依次运行的插件处理函数:每个函数接收前面修改后的提案,继续调整这一步的输入或执行决定。

插件最后给出的 enter 或 reject 决定是否执行该 Step。获准后才追加 step/start。如果被拒绝,Turn 可以以 blocked(被阻止)结束;首批输入处理后为空时,也可能直接结束 Turn,不开始模型 Step。前面已经 claim 的输入默认不会自动放回队列,因此拒绝执行不等于把消息留待下一轮再试。

这些边界都可以在Agent 实现和Prompt 组装入口中定位。后续专篇会拆开 Prompt 节点、上下文来源和各个处理函数的修改规则。

先准备调用,再提交模型可见输入 #

进入 Step 后,prepareRequest() 根据 Agent 实例的模型选项和已记录的请求头构造请求提案,交给 agent/request 的处理函数调整,再调用 llm.prepareCall()。正常 adapter 路径返回一个 prepared call 对象:它已确定模型路由和配置,只允许实际发起一次调用。Adapter 是对接具体模型服务的适配器,后文会说明它如何发送请求。

准备与取消检查通过后,Step 同步处理 system prompt(系统提示)和获准输入:追加或替换 system/message,在首次请求尝试(attempt)中提交用户消息,并处理本次运行提供的上下文。然后根据变化记录 request/header、request/context,必要时记录工具注册变化。

模型历史从 session.deriveMessages() 取得。这里的“投影”指从已提交事件推导出当前消息序列;发送前再冻结消息和请求对象。要回答模型看到了什么,应沿这份日志投影和请求构造追踪,不能仅检查客户端界面。

未注册 adapter 的 NO_ADAPTER 还保留中间件接管分支,因此“缺少 adapter 一定在用户消息写日志前结束”也不适合作为所有配置的统一结论。本文的主路径选择已配置 adapter 的正常分派。

模型流如何变成回答和工具调用 #

Adapter 连接具体 provider #

prepared call 的 stream() 仍经过 llm/stream waterfall,然后进入具体 adapter。统一接口使 Agent 消费相同的流式块结构,provider 的鉴权、消息序列化和传输格式则由适配器处理。

固定版本的 DeepSeek adapter 在准备图像、授权和文件引用后,将请求 POST 到规范化 API 根路径下的 /messages。返回内容通过服务器发送事件(Server-Sent Events,SSE)解析,再转换成统一的 StreamChunk。

这说明 provider 端的网络协议应按具体 adapter 分析。这里的 SSE 也不意味着浏览器与 Host 的会话下行使用同一种传输协议。

实时片段与结算记录各有用途 #

Agent 用 AssistantStreamAttempt 组装陆续到达的内容块,并通过实时 agent/assistant-stream 把更新发出去。每份这样的实时更新称为一帧。Attempt 表示一次实际请求尝试,系统据此区分成功响应、失败后的重试,以及中断前只收到了一部分内容的情况。请求结束时还要保存相应记录,后文称这一步为“结算”。

成功响应会提交 assistant/message,保存内容、使用量和该次请求尝试的流记录。请求失败,或中断且没有形成会话消息时,会使用 assistant/attempt 等路径保存结束记录。若取消前已经产生可见内容,还要进入对应分支处理,不能把所有中断都记成没有内容的失败。

这两类数据回答的问题不同:实时帧说明当前正在生成什么,结算事件支持重放和后续读取。进程若在结算前硬退出,不能保证界面上已经显示的片段都有可恢复记录。上述处理位于核心 Agent 循环。

工具结果怎样进入下一次请求 #

第一次请求发给模型时,消息里有“读取 README.md”的任务,还没有文件内容。模型返回一个 read 调用以后,dsh 必须执行工具、保存返回值,再把包含结果的历史组织成下一次请求。下面沿着同一个调用 ID c1 追踪这段过程。

图中颜色固定对应模型、Agent、工具运行时和 Session。点击“播放链路”可以经过六个阶段;用左右按钮停在某一步,再切换“消息历史”“事件载荷”或“执行职责”。图内的链接也能切换观察视角。事件序号和文件内容是教学示例,箭头速度只用于演示传递方向。

dsh 工具反馈回路:调用登记、执行、结果提交与下一轮消息 · 在独立窗口中查看

模型提出调用,运行时负责执行 #

在贯穿全文的假设任务里,第一次模型响应提出 read 调用。下面是原生文件工具参数的示意,不是可以直接发送给所有 provider 的完整网络载荷:

{
  "name": "read",
  "arguments": {
    "file_path": "README.md"
  }
}

工具名和参数可以在 read 实现中核对。实际返回什么,取决于运行工作区、文件状态与工具配置;动画使用合成内容,不代表读取了 dsh 仓库的 README。

Agent 将成功响应中的工具调用交给 executeToolCalls()。调度器解析参数,并按 executionMode 区分可安全并发和必须独占执行的调用。独占调用要与其他调用错开执行,这就是这里的“执行屏障”;允许并发的调用也受最大并发数限制。尚未开始的调用还会重新判断所属类别。

开始执行的调用先记录 tool/call。运行时负责参数处理、tools/pre-execute、审批与 guard 等策略,再通过 tools/execute 执行工具主体,之后处理结果。Guard 在这里承担继续执行前的检查,策略组合的完整规则见后续工具与安全篇章。不同错误分支可能跳过部分后置处理,不能只用“调用函数后返回字符串”概括整个工具流水线。

这里有两种关联,容易混在一起。c1 是模型给出的调用 ID:tool/call 保存为 callId,工具消息保存为 toolCallId,用来把调用与返回值对上。会话事件序号则标识日志里的某条记录:appendToolResult() 把 tool/call 的序号放进 sourceEventSeqs,保存结果来自哪条调用记录。图里的 42 是演示序号,不等于调用 ID,也不是实际运行日志。

完成顺序与写入顺序可以不同 #

工具可以并发完成,但 tool/result 仍按模型调用顺序提交,并引用对应的调用事件。附加上下文也按提交顺序进入下一 Step。这样,模型历史中的结果顺序有确定依据,不取决于哪个异步动作更早返回。

图中只演示一次调用。多个工具同时执行时,“哪个先完成”和“哪个先提交”需要分开观察;调度器的结果槽位与连续提交机制会在工具执行篇章中展开。

取消会停止启动新的调用,并等待已经开始的调用返回,保存其中可以提交的结果。保存日志不能撤销已经发生的文件修改、进程行为或外部请求。这些调度和策略边界分别位于工具批次实现与工具运行时。

没有工具调用的正常响应可以完成当前 Step,但这不自动决定整个 Turn 已结束。工具结果中的 concludesTurn 会参与结束判断;队列里是否还有待处理的 next-step 输入,以及准备自然停止时的 agent/turn-stopping 处理结果,也会影响是否继续。因此要分别观察 step/end、turn/end 和实时 idle 状态,确认结束的是一个模型步骤、一轮执行,还是 Agent 当前已无执行活动。

下一次请求怎样得到文件内容 #

在获准继续的正常路径里,下一 Step 的 prepareRequest() 调用 session.deriveMessages(),读取当前参与消息构造的事件记录,也就是“会话表面”,并生成消息历史。历史已包含助手提出的 read 调用,以及带 toolCallId = c1 的工具消息;文件内容就在工具消息的 content 里。

图里的第五阶段展示派生后的消息,第六阶段展示请求边界:dsh 冻结边界消息与请求对象,固定本次采用的输入,再把请求交给 adapter.stream()。因此,模型的下一次生成能够利用读取结果。图中只展示 user、assistant、tool,实际请求还可能有其他上下文消息与配置。

工具结果已提交,只能说明返回值已进入会话记录。下一次请求是否发出,还要经过后续 Step 的执行判断。图中展开的是正常继续路径。

回答经另一条链路到达浏览器 #

浏览器提交 prompt() 以后,主要通过已建立的会话订阅持续接收结果。SessionHistoryController.follow() 先订阅事件和可选的实时帧,再获取 snapshot/baseline,随后按游标发送新增记录。游标用来记住记录已经发送到哪里。这套流程要处理一个衔接问题:获取快照期间也可能产生新事件,后续发送必须接上这部分记录;实现还会检查日志序号是否出现缺口。

这里的 snapshot 是某一时点的记录快照,baseline 是继续接收增量所依据的起点。另一个 control stream 提供 Host 范围的投影状态,供队列、运行状态等消费者使用。

客户端 Session 应用日志替换、追加和流帧变化,ClientAssistantStream 协调实时 attempt、结算结果和 Step 结束。部分已经到达的持久结果会暂存到相应结算边界,以免同一段内容由实时流和最终记录各显示一次。

下图把成功路径按时间展开。为便于阅读,持续下行在图末合并表示,实际更新贯穿执行过程。

dsh 请求时序:提交确认与 Agent 执行可以交错;模型工具调用产生结果,下一 Step 生成回答,经已建立的会话订阅更新客户端。

下载可编辑的 Mermaid 时序图。

日志提交与刷盘是两个边界 #

Session.commit() 校验候选事件,将其加入内存日志,再通知 session/event 观察者。模型历史投影和客户端增量消费的是已经提交的事件。

JSONL 持久化 provider 负责把记录保存到文件。它的事件观察者把已提交事件依次排入写入队列。JSONL 指每行保存一个 JSON 对象的存储格式;入队后仍需等待队列中的写入完成,并通过 provider 的 drain、flush 流程确认保存进度。

因此,排查状态时可以依次核对:Host 是否接收输入,Inbox 是否记录,消息是否进入模型历史,响应是否结算,以及持久化 provider 是否完成相应写入。sessions.flush(session) 是另一个需要显式等待的边界。仅观察 accepted 或 session.append(),无法证明后面的磁盘写入已经完成。

工具反馈图展示调用、会话事件和后续消息,未模拟磁盘刷写的完成时间。

同一个任务从 Headless 进入会怎样 #

Headless 省去了浏览器和 Web RPC。runner 等待 Loader 完成组合,读取命令行或 stdin 任务,选择模型与工作目录,然后创建 Agent 或恢复指定 Session,直接调用 followup()。

环节Web 场景Headless 场景
输入草稿、本地回显、Remote prompttask 参数或 stdin
获取 AgentHost 解析或恢复已有会话runner 创建或采用会话
核心执行Agent Loop、模型与工具同一套核心循环
结果观察follow/control 与客户端合并实时输出或 JSON 投影、最终文本汇总
结束浏览器继续保留会话,可提交下一条消息等待相关活动稳定,flush 后汇总并退出

Headless 的完成判断还要考虑相关子 Agent,以及子任务结算是否再次唤醒父 Agent。退出码来自汇总的 Turn 结束原因,“当前 idle”不足以单独证明成功。它还会在退出前显式等待 sessions.flush(),因此可以清楚看到执行结束和记录刷盘的区别。

失败后从哪个位置继续看 #

成功路径提供系统地图,失败分支说明这张地图的边界。遇到“没有回答”时,先确定输入停在哪个阶段,再去看对应实现。

失败位置实现与排查边界
输入或附件校验Host 返回业务错误,正常路径不会把非法输入送进 Agent 队列
Session 解析或所有权检查查看目标身份、恢复与写入冲突,不能默默换成另一会话
pre-step 拒绝或输入最终为空Turn 可能没有模型 Step;已 claim 的批次默认不重新入队
请求准备失败区分路由、配置、取消和中间件接管,核对用户消息是否已提交
provider 的终止失败attempt 先结算,再由 agent/request-error 决定是否在原 Step 内重试
中间件或请求消费者抛错与 adapter 的终止 failure 区分,不能假定所有错误都进入同一恢复入口
工具错误或取消检查调用、策略和结果记录;已经发生的副作用需要单独处理
持久结算或 flush 失败区分实时可见、内存日志提交和存储写入完成
客户端增量不连续检查日志游标、流帧序号和 attempt baseline,不能直接叠加内容

原 Step 内的重试会重新准备调用和请求上下文,但不会重复 pre-step 与用户消息提交。取消检查还能阻止继续重试。相关断言可以在 request-error.spec.ts 中阅读。

取消客户端的一次 Remote 提交,与取消已经运行的 Agent 也属于不同入口。若要判断工具是否停止,需要继续观察 Agent 的取消和工具清理路径;客户端连接状态本身无法给出这个答案。

如何核对本文,而不把示意当成实测 #

本文从 2026-10-12 的静态研究继续整理,2026-10-14 整理成文,源码仍固定为同一 commit。已有测试帮助核对实现意图,本轮没有执行这些 dsh 测试。

可以复核的问题仓库中的测试入口
简单 Turn 的边界、工具结果进入后续请求loop.spec.ts
同 Step 重试、取消与恢复分支request-error.spec.ts
请求冻结与可取消的 signalrequest-freeze.spec.ts
并发、独占屏障与按序结果提交tool-calls.spec.ts
实时帧与持久结果在客户端合并assistant-stream.client.spec.ts
Headless 等待、汇总和退出前 flushheadless.spec.ts

loop.spec.ts 中已有 MockAdapter 与 echo fixture 的往返案例:第一次响应提出工具调用,第二次请求包含对应工具结果。这可以用来复核核心反馈循环。本文的 read 例子则是依据实际工具接口编排的教学场景,二者的证据性质不同。

实际 Web 任务还可能触发标题生成、压缩等外围模型调用,不能把一个核心循环例子里的“两次请求”当成整个产品任务的总调用数或成本。真实 provider、重连、磁盘故障、沙箱、跨平台行为、成功率和延迟都需要另外记录运行条件与结果。

读完这条链路后 #

沿一条消息追踪,可以得到几个后续源码阅读的落点:客户端先建立提交状态;Host 接收并入队;请求准备决定输入何时成为模型消息;模型与工具在 Step 之间形成反馈;实时帧和日志通过独立订阅更新界面;磁盘持久化再提供自己的完成边界。

下一步可以从源码地图和应用组合继续阅读,再分别展开 Cordis、Turn/Step、Prompt、Session 与工具流水线。本文负责把它们连起来,各机制的全部配置、类型和边界会在对应篇章继续拆解。

延伸阅读 #