DeepSeek Harness 怎样把模型的一次回答变成可继续执行的任务
模型知道 BigDecimal.equals() 会区分 scale(小数位数),不代表一个 Agent 就能修好 Java 项目。它还得读对文件、执行修改、跑完测试,把失败结果带进下一次请求;执行到一半断了,还得知道哪一步能重试。
DeepSeek Harness 值得拆的部分,就藏在这些动作之间。下面沿着一个测试修复任务,读它如何把流式输出、工具执行、事件日志和下一次模型请求接起来。重点看四个实现选择,以及省掉它们会出什么问题。
本文为静态源码分析,固定在官方仓库提交 ce7073d。Java 项目、调用参数和测试输出是讲解用的示例,没有实际运行;标明“源码”的 TypeScript 片段来自该提交。
从一个金额比较失败开始
假设业务要求:金额数值相同就视为相等,10.0 和 10.00 不因小数位数不同而不等。现有方法却写成:1
2
3
4
5static boolean sameAmount(BigDecimal a, BigDecimal b) {
return a.equals(b);
}
// 失败的测试
assertTrue(sameAmount(new BigDecimal("10.0"), new BigDecimal("10.00")));
用户要求修复它,并通过 MoneyTest。合理的执行过程是:先读实现和测试,确认业务语义;把比较改为 a.compareTo(b) == 0;执行 mvn -q -Dtest=MoneyTest test;根据真实结果决定继续修,还是报告完成。
这里先约定两个词。turn 是一轮任务处理边界;step 是其中一次模型请求以及随后的工具执行。一次修复可能需要多个 step。默认驱动类叫 ReactLoopAgent,这里读的是模型与动作反复交替的循环,别把它当成 React 前端组件。
源码主线可以压缩成下面这条路径,后文逐段展开:1
2
3
4
5
6
7
8
9
10turn()
→ preStep() 领取输入,确定本步模型、工具集合和提示词
→ step()
→ prepareRequest() → llm.prepareCall()
→ 记录本次需要加入的系统消息、用户消息
→ buildRequest() → session.deriveMessages()
→ stream() → AssistantStreamAttempt → BlockAssembler
→ append assistant/message
→ tools.execute() → 工具实现 → append tool/result
→ 下一 step 再从 Session 推导消息,重新请求模型
preStep() 得到的 toolSet 同时用于本步工具声明与执行,直到这批工具收尾才释放。模型看到什么工具、这一批调用依据什么工具集合执行,由同一个 step 关联起来。循环准备与执行源码
半截工具参数不能执行 完整调用也要先进入日志
模型要求运行测试时,Harness 内部的一个工具调用块可以长这样。下面是示例数据,采用真实字段;它不是完整的供应商 HTTP 请求:1
2
3
4
5
6{
"type": "tool-call",
"id": "call-test-1",
"name": "bash",
"arguments": "{\"description\":\"运行 MoneyTest\",\"command\":\"mvn -q -Dtest=MoneyTest test\"}"
}
注意 arguments 是字符串。模型通过流式响应把它分段送来:前一块可能只有 {"description":"运行,后一块才补齐其余内容。即使终端已显示“准备运行测试”,真正的调用也可能还没完整生成。示例使用的 bash 工具确实要求 description 和 command。工具定义
AssistantStreamAttempt.push() 把同一份流片段交给累积器、消息组装器和实时显示事件。BlockAssembler 遇到 tool-call-delta,先累计调用 ID、名字和参数字符串。实时显示和执行承诺是两个时点:流还没结束时,可以显示进度,但默认循环不会因此启动工具。
流结束后的关键顺序在 step() 中。以下为源码摘录,仅省略消息元数据和配置读取,保留执行顺序:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17live.settle(
'assistant/message',
() => this.session.append('assistant/message', {
// ... turn、step、message、usage、stream
}, { surfaceOp: 'append' }).seq,
)
if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }
const toolCalls = message.content.filter(block => block.type === 'tool-call')
if (toolCalls.length === 0) return { kind: 'completed' }
// ...读取 maxParallelToolCalls
const result = await this.loopCtx.tools.execute({
calls: toolCalls, signal, agent: this, log: { turn, step },
maxParallelToolCalls, toolSet: decision.toolSet,
})
// ...把 additionalContexts 排入下一 step
return result.concluded ? { kind: 'completed' } : null
按顺序读这几行:
session.append先记录完整的 assistant 消息,其中包括模型提出的工具调用。surfaceOp: 'append'表示这条消息要进入后续模型上下文,第三节会解释它。如果响应因输出长度上限结束,就返回
max-tokens,不进入工具执行。没有工具调用,当前 step 可以完成;有调用,才把它们交给执行器。
普通工具批次没有声明
concluded时返回null,驱动会继续下一步。工具调用结束不等于用户任务已经完成,失败的测试输出正是下一次判断的输入。
还有双重保护:组装器本身在 max-tokens 时就会去掉工具调用块;流被取消时,interruptedBlocks() 只保留可用的文本和推理内容,不保留工具调用。这样不会把“模型只写出一半的动作”当成待执行任务。消息提交顺序、流组装与截断处理
最朴素的实现会在流里识别到工具名和一段看似合法的 JSON,就立即调用函数。这既可能执行尚未确定的参数,也难以回答“到底是哪次模型输出触发了这个操作”。这里先形成正式消息,再启动执行,留下了明确的因果顺序。代价是要等本次流结束,不能靠提前猜测调用来抢那一点延迟。
不过,“先进入 Session 日志”不要读成“每次执行前都已刷盘”。持久化是另外的能力,append 和持久化屏障 flush 也有区别;这段代码没有承诺工具执行前逐次刷盘。持久化接入契约
工具可以同时运行 结果仍按模型给出的顺序提交
假设第一步要读 Money.java 和 MoneyTest.java。两个读取互不依赖,可以并行;但“修改金额比较”和“运行验证测试”存在先后依赖,测试必须看到修改后的文件。
Harness 的原生执行路径是:1
2
3
4
5tools.execute()
→ 选择当前作用域的执行器
→ NativeToolExecutor.execute()
→ executeToolBatch()
→ pipeline.prepare() → pipeline.dispatch() → pipeline.complete()
参数字符串先由批处理代码解析;如果不是合法 JSON,会保留原字符串交给工具管线形成参数诊断,而不是用 eval 当代码执行。prepare 处理执行前策略、审批与拒绝条件,dispatch 进入工具实现,complete 完成结果处理。调度器允许工具主体重叠运行,但执行前处理和结果提交仍按模型给出的顺序推进。执行器分派、执行管线
这不是把所有调用丢给 Promise.all。executionMode() 只有在工具的 isConcurrencySafe(arguments) 明确返回 true 时,才允许并行。没有声明、返回其他值或判断抛错,都会归为独占调用。独占调用相当于一道屏障:前面的并行组收完,它单独执行完,后面的调用再开始。**调度器信任工具的并发声明,不会替工具分析两个业务操作是否冲突。**因此也不能因为两条命令看起来都是读操作,就断言当前 bash 调用自动并行。并发分类源码
更细的设计是“完成顺序”和“提交顺序”分开。假设模型先提出读取 A,再读取 B,B 却先完成。调度器把 B 的结果放进对应的 slots,不会抢先写入模型历史。下面是 commitReady() 的核心源码摘录:1
2
3
4
5
6
7const slot = slots[committed]
if (slot === undefined) break
const call = group[committed]
const result = await pipeline.complete(slot.exec, slot)
settlement.commit(call!.block, result, callSeqs[committed])
concluded ||= result.concludesTurn === true
committed++
committed 指向“下一份应该提交的结果”;call! 中的 ! 是 TypeScript 的非空断言,不会生成运行时检查。第一行只查看这个位置;第二行发现它尚未完成,就暂停提交,哪怕后面的槽位已有结果。随后做结果后处理,把结果与原调用及 tool/call 序号关联,再向前移动。这使日志和附加上下文保持模型顺序,不受某次磁盘读取谁快谁慢影响。代价也很明确:A 特别慢时,已经完成的 B 要等它,形成队头等待。有界并发与有序提交
回到金额修复:读出的代码返回模型,模型判断 equals 不符合本例语义;修改完成再运行测试;测试工具返回的日志进入下一次请求。模型因此能看到“测试确实通过”或“仍有失败”,而不是根据自己刚写了什么来猜结果。
下一次请求只取日志中的可见消息 压缩也不必删掉原始证据
工具执行完,并没有把结果直接塞进某个独立的 messages 数组就算结束。BatchSettlement 追加 tool/result,并用 sourceEventSeqs 关联对应的 tool/call。下一步重新走 buildRequest(),其中一个关键调用是:1
const boundaryMessages = session.deriveMessages()
它返回本次请求要使用的历史。循环冻结这些消息和请求对象,再交给模型层;后续追加不会偷偷长进已经构造好的消息数组。工具结果入账、请求快照
但 deriveMessages() 不会把整个事件日志原样交给模型。日志里有 step/start、调用开始和请求配置等运行信息,它们不是聊天消息。Session 另外维护一个 surface,可以理解为“当前模型可见消息的有序索引”,索引指向原始事件。
以前面的测试命令为例,下面只展示关键字段,是结构示意,不是完整日志文件:1
2
3
4
5
6
7
8事件日志 当前 surface
user/message 用户要求修复 MoneyTest 加入
assistant/message 含 call-test-1 工具调用 加入
tool/call callId=call-test-1 不加入
tool/result message.role="tool" 加入
message.toolCallId="call-test-1"
content=测试输出
step/end 不加入
因此模型下一次得到的是“用户任务 → assistant 发起的调用 → 与调用配对的工具结果”,无需理解内部调度事件。事件日志负责保留经过,surface 负责选择此刻该给模型看的内容。deriveMessages() 从 surface 推导消息;纯追加时增量处理新增节点,surface 替换或消息投影变化会触发缓存重建。返回数组仍要复制一遍完整消息列表,不能把整个方法的成本理解成只与新增量有关。消息推导实现
这层区分直接解决长任务里的一个矛盾:调查多个失败分支后,几百行 Maven 输出不值得每次都发,但排查证据又不该直接删除。
可选的 compaction-basic 压缩插件将一段历史总结成较短的检查点。提交摘要时,源码追加这样一条消息:1
2
3
4session.append('user/message', checkpointMessage, {
surfaceOp: { op: 'replace', startSeq: start, endSeq: end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
第一行追加新的检查点消息;第二行让它在 surface 中替代选定区间;第三行保留它与压缩操作、摘要及被遮蔽原事件的关联。原日志还在,下一次模型请求却能变短。假设原 surface 是 [用户任务, 读取调用, 读取结果, 修改调用, 修改结果, 最近测试调用, 最近测试结果],选中的完整调用与结果区间可以被一个摘要节点替代;替换后模型看的是摘要加保留的近期历史,审计仍能沿事件序号回到原始内容。压缩替换源码
替换边界也不能随便选。假如删掉 assistant 的测试调用,却留下它的 tool 结果,模型协议中的配对关系就断了。实现会检查区间两端的工具调用与结果是否平衡,拒绝切断配对。只按字符数删掉前半截聊天记录,恰恰容易犯这个错。区间检查
对本例,一个有用摘要应保留“金额忽略 scale”“原实现使用 equals”“已改为 compareTo”“哪次测试实际通过”等事实;这是摘要应该达到的效果,不是实现能自动保证的正确性。压缩节约上下文,却引入信息损失,而且不会顺便解决原始日志的存储体积。
中断恢复必须区分还没开始和结果已经无法确认
最能看出这套日志价值的时刻,是改文件或跑测试时进程断了。
ToolCallRecovery 跟踪三类事实:assistant 提出了哪个调用、调度器是否记录了 tool/call、是否已有配对的 tool/result。下面讨论同一会话中断后的恢复;fork 分支的提示还有额外语义。恢复时不会把“没有结果”统统解释成“工具没执行”。源码中决定错误类型的几行是:1
2
3
4
5const started = callSeq !== undefined
// ...构造配对的错误工具结果
error: started
? { name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN }
: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
这里的 started 是日志记录意义上的开始。settlement.start() 发生在 pipeline.prepare() 之前,所以出现 tool/call 甚至不保证已经进入工具主体。它只能把恢复范围分成两种:
**保存的历史中没有开始记录。**恢复补上
TOOL_NOT_STARTED,中断恢复提示是仍有需要时重试。这是基于已保存日志作出的分类,不能越过前述刷盘窗口,推断外部世界绝对没有发生操作。**记录了开始,但没有结果。**恢复补上
TOOL_OUTCOME_UNKNOWN,说明无法从保存的历史确认结果。源码给模型的指引是:只读或幂等操作才考虑直接重试;可能有副作用时先核对外部状态,不要盲目重试。
如果已经有配对结果,就不再补一份。未闭合的 step 和 turn 也会补上结束事件,使后续历史保持完整。恢复状态判定、调用开始的实际位置
放回 Java 例子:若 Money.java 已改好,但修改工具来不及记录结果,恢复后应先读文件或检查 diff,再决定是否重做补丁。若测试命令结果丢失,要按测试是否有外部副作用判断能否重跑;不能因为名字叫“测试”就默认无副作用。
这个设计接受一个分布式执行中的现实:文件系统变化、数据库提交与 Session 日志并不天然属于同一个事务。“外部操作已成功,结果记录尚未保存”这段窗口客观存在。Harness 能留下明确的未知状态,让下一步作保守判断;它不能仅凭日志保证所有外部操作恰好执行一次。
修复闭环的关键是把已发生和未知分清楚
整个金额修复过程最终回到同一条链:模型提出动作,Harness 形成消息并记录;执行器调度工具,记录实际结果;下一次请求从日志的可见索引重建,模型继续判断。流被截断时不执行半成品调用,工具并发时不打乱结果顺序,历史过长时替换可见内容,中断时保留“结果未知”而不伪造成功。
这四处实现值得借鉴,因为它们分别回答了一个可检查的问题:**何时允许执行,结果按什么顺序提交,下次模型到底看到什么,中断后究竟知道多少。**把这些问题讲清楚,一个 Agent 才不只是能连续调用模型,而是能交代每一步为什么发生、发生到哪里。