DeepSeek Harness 源码解读(五):工具明明并发执行,结果为什么还按顺序写入

第四篇追到 executeToolCalls() 时,我原本以为后面就是一层普通封装:按名字找到工具,调用 execute(),再把返回值交给模型。

真正读进去,最费脑子的反而不是"怎样调用工具",而是顺序。

同一个 Assistant 消息可以带多个 tool call。读文件一类的调用可以并发,写文件或未知工具要独占;某个调用可能后启动却先完成;用户也可能在中途取消。可最终写入 Session 的 tool/result 仍要和模型给出的调用顺序一一对应,下一次模型请求还必须看到一段可以重放的完整历史。

DeepSeek Harness 没有靠"所有工具串行执行"换取简单,而是把整条链拆成了有序的外层和可并发的中段:

assistant/message → tool/call → prepare → dispatch → finalize → tools/result → tool/result

项目地址:deepseek-ai/deepseek-harness

源码基线:0.1.0-rc.5

模型消息写完,工具调度才开始

入口仍在 packages/core/agent-loop/src/agent.ts

模型流结束后,Agent Loop 先把完整的 assistant/message 写进 Session,再从消息内容中筛出 tool call:

ts 复制代码
this.session.append('assistant/message', {
  turn, step, message, ...usage,
}, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs })

const toolCalls = message.content.filter(
  block => block.type === 'tool-call',
)
if (toolCalls.length === 0) return { kind: 'completed' }

const { concluded } = await executeToolCalls(
  this.loopCtx,
  turn, step, toolCalls, signal,
  context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]),
)

这个顺序很重要。工具有没有成功、是否被拒绝、是否来得及启动,都不会改变模型确实发出过这批调用的事实。回放 Session 时,先看到 Assistant 提出调用,再看到每个调用的处理结果。

executeToolCalls() 收到的数组也保留模型原始顺序。它先为每一项准备运行输入:

ts 复制代码
const planned: PlannedCall[] = toolCalls.map(block => ({
  block,
  exec: {
    callId: block.id, name: block.name,
    arguments: parseArguments(block.arguments),
    agent, signal,
  },
}))

function parseArguments(raw: string): unknown {
  try {
    return raw ? JSON.parse(raw) : {}
  } catch {
    return raw
  }
}

这里没有急着用 schema 校验。合法 JSON 先变成对象;解析失败则保留原始字符串,后面由工具运行时产出结构化错误。这样一次坏参数仍然会走完 tool/call → tool/result,而不是在 Agent Loop 里突然抛断整段会话。

tool/call 先入账,prepare 再决定能不能跑

调度器真正启动某一项时,第一步不是权限判断,而是追加 tool/call

ts 复制代码
const startCall = async (index: number): Promise<void> => {
  const call = group[index]!
  callSeqs[index] = appendToolCall(session, turn, step, call.block)
  started++
  const prepared = await scheduler.prepare(call.exec)
  // 根据 prepared.kind 决定 dispatch、post-result 或 final-result
}

tool/call 保存的是模型给出的原始参数字符串。随后进入 ToolRuntime,解析后的参数会再做一次无损 JSON 快照并深度冻结:

所以同一个调用有两种参数视图:Session 里保留模型原文,运行时拿到的是脱离调用方引用、不可再修改的 JSON 值。策略插件、工具本体和结果观察者看到的是同一份稳定参数,而不是一个可能被前一个监听器改掉的对象。

prepare 接着处理三层判断:

ts 复制代码
const gate = await this.ctx.waterfall(
  carrier, 'tools/pre-execute', exec,
  () => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)

const askResolution = gate.kind === 'ask'
  ? await this.serviceAsk(exec, gate)
  : { decision: gate, approvalCancelled: false }
const denialReason = askResolution.decision.kind === 'allow'
  ? this.guardReason(exec)
  : askResolution.decision.reason

tools/pre-execute 可以 allowdenyask。它是 waterfall,前面的监听器可以直接决定,也可以调用 next() 把决定权交给后面。

守卫不一样。tools.guard() 只返回拒绝理由或不表态,而且在 waterfall 得到 allow 以后才检查。也就是说,可扩展策略即使允许,拥有方守卫仍能收紧权限;没有任何后续监听器能把这个拒绝翻回允许。这就是代码里所说的 monotonic guard。

被拒绝的调用不会进入工具本体,但仍会生成一个普通的错误结果,继续经过需要的后处理并写回 Session。对模型来说,它不是"调用消失了",而是"调用明确失败了"。

通用审批和 Bash 沙箱升级不是一回事

tools/pre-execute 返回 ask,ToolRuntime 才会查找 ctx.approval

只有 allowed-once 会继续执行。没有 ApprovalService、调用没有所属 Agent、用户拒绝、请求被取消,或者审批通道无人应答,最后都会变成拒绝。ApprovalService 还会在当前 Turn 中成对写入 approval/askedapproval/decided,让一次授权为什么发生、得到什么结果都能回放。

这里很容易把所有审批都归到 tools/pre-execute,但 Bash 的沙箱升级不是这条路径。

packages/shell/tool-bash/src/index.ts 在工具本体内部检查 sandbox_permissionsjustification,再调用共享的升级审批逻辑:

ts 复制代码
const approvedMode =
  args.sandbox_permissions !== undefined
  && args.justification !== undefined
    ? await approveBashEscalation(
        args.sandbox_permissions, args.justification, exec, standingPolicy,
      )
    : undefined

const policy = approvedMode === undefined
  ? standingPolicy
  : { ...standingPolicy, mode: approvedMode }

两类审批最终都使用 ApprovalService,但发起者不同:前者是工具运行时对整次调用的通用门禁,后者是 Bash 已经进入工具实现后,对某个更宽沙箱模式的一次性升级。

这个区别会直接影响排查。看到 approval/asked 不能立刻断言某个 tools/pre-execute 插件要求了审批,还要看调用参数里是否带了 Bash 升级请求。

真正并发的只有 dispatch

每个工具可以用 isConcurrencySafe(args) 声明当前调用是否适合并发。ToolRuntime 的判断很保守:

ts 复制代码
executionMode(exec: ToolExecutionInput): ToolExecutionMode {
  const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined)
  if (!tool?.isConcurrencySafe) return { kind: 'exclusive' }

  try {
    return tool.isConcurrencySafe(exec.arguments) === true
      ? { kind: 'parallel' }
      : { kind: 'exclusive' }
  } catch {
    return { kind: 'exclusive' }
  }
}

只有严格返回 true 才能并发。没有声明、抛异常、工具不可见或工具未知,都会按独占处理。

独占调用还是一道屏障。假设模型依次请求"读 A、写 B、读 C",两个读调用都并发安全,也不能越过中间的写调用同时运行。调度器会形成三个组:读 A 完成后执行写 B,最后才开始读 C。

并行组内部也不是一次 Promise.all() 全部放出去。它维护一个滚动池,最多同时运行 maxParallelToolCalls 个调用,默认上限是 10。每个新调用启动前还会重新读取执行模式,工具注册或策略在运行中变化时,尚未启动的调用可以立即退回独占屏障。

最关键的一段在 startCall():它会等待当前调用的 prepare 完成,但只把 dispatch Promise 放进 inFlight,不会在这里等工具结束。

ts 复制代码
const prepared = await scheduler.prepare(call.exec)

if (prepared.kind === 'dispatch') {
  const promise = scheduler.dispatch(prepared.exec).then(outcome => {
    slots[index] = {
      exec: prepared.exec, result: outcome.result,
      needsPost: outcome.kind === 'post-result',
    }
    return index
  })
  inFlight.set(index, promise)
}

因此,tool/callpreparetools/pre-execute 和审批仍按模型顺序发生;能够重叠的只有 dispatch,也就是 tools/execute 包装器与真正的工具本体。

工具完成后先把结果放进对应 slot。谁先完成不等于谁先提交,commitReady() 只从当前模型序号开始,提交一段连续就绪的结果:

ts 复制代码
const commitReady = async (): Promise<void> => {
  while (committed < group.length) {
    const slot = slots[committed]
    if (slot === undefined) break
    const result = slot.needsPost
      ? await scheduler.finalize(slot.exec, slot.result)
      : scheduler.finish(slot.exec, slot.result)
    appendToolResult(
      session, turn, step, group[committed]!.block,
      result, callSeqs[committed]!,
    )
    committed++
  }
}

如果模型顺序是 A、B、C,而完成顺序是 B、C、A,B 和 C 只会先待在 slots 里。A 到达以后,调度器才按 A、B、C 依次运行 post-execute、最终内容处理和 Session 提交。

这张图把"有序外层、并发中段"放在一起:

超时插件也只包住这段可并发区。packages/guard/timeout-policy/src/index.ts 临时换上派生 signal,等待下游真正停稳,再判断是不是自己的定时器先触发:

ts 复制代码
using deadlineState = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
const upstream = exec.signal
exec.signal = deadlineState.signal
try {
  const result = await next()
  if (timeoutOf(deadlineState.signal, TOOL_TIMEOUT) !== undefined) {
    return toolTimeoutResult(timeoutMs)
  }
  return result
} finally {
  exec.signal = upstream
}

这里没有用超时 Promise 抢跑后直接返回。工具必须配合 exec.signal 结束清理,await next() 才会回来;随后插件把结果改写成结构化的 TOOL_TIMEOUT。原始取消 signal 还会在工具本体前重新融合,所以包装器也不能把用户取消悄悄摘掉。

工具返回值不会直接塞回模型

工具本体返回的是内部值 value。ToolRuntime 先做无损快照,再按工具声明的 output schema 校验,最后才调用 render() 生成模型可见的 content

ts 复制代码
const detached = snapshotToolValue(tool.name, candidate)
const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value')
if (violations.length > 0) {
  throw new ToolOutputError(tool.name, violations)
}
const value = deepFreeze(detached)
const rendered = tool.output.render(exec.arguments, value)
const content = snapshotProjection(tool.name, 'render', rendered)

这一步把"工具内部结果"和"给模型看的内容"分开了。比如文件编辑工具内部可以保留结构化 diff,模型只看到适合继续推理的文本块;UI 需要的卡片信息则通过 meta 单独投影。

接着才轮到 tools/post-execute。它可以接受原结果、替换 content、替换成功 value、附加 additionalContexts,或者用 block 把反馈变成错误。替换 value 时会重新走 schema 和 render();同时替换 valuecontent 会直接报错,避免内部值和模型内容彼此矛盾。

additionalContexts 不会混进当前 tool/result 文本。Agent Loop 在按序提交结果时,把它们放进 next-step Inbox,下一次模型请求才会看到。工具调用乱序完成,也不会改变这些上下文的模型顺序。

工具定义还可以提供 finalizeContent,但它只能在最后改 content,碰不到 value、错误分类和调用身份。到这里,最终结果会再次物化并深度冻结。

多一个 s 的 tools/result 不是会话事件

结果冻结后,ToolRuntime 先发出 tools/result。这是 Cordis 的实时观察事件,给指标、日志桥接和 UI 之类的监听器看;监听器失败只记警告,不能反过来改写工具结果。

随后 Agent Loop 才追加 tool/result

ts 复制代码
const message = createToolResultMessage({
  callId: block.id,
  content: result.content,
  isError: result.isError,
})

session.append('tool/result', {
  turn, step, message,
  ...(result.error?.info ? { error: result.error.info } : {}),
  ...(result.meta !== undefined ? { meta: result.meta } : {}),
}, {
  surfaceOp: 'append',
  sourceEventSeqs: [callSeq],
})

两个名字只差一个 s,职责完全不同:

  • tools/result 是运行时通知,不承担持久化;
  • tool/result 是 Session 事实,会进入回放和下一次 deriveMessages()
  • 会话事件保存模型可见 content、错误信息和展示 meta,不会保存工具内部成功值 value

最后一条能避免 Session 格式被某个工具的私有对象结构绑死。需要重放 UI 卡片的字段放进 meta;只对当前执行树有意义的规范值,留在内存里。

concludeTurn() 也在这里收口。它只能附着在成功结果上,Agent Loop 等整批结果按序提交完,才根据 concludesTurn 决定是否结束当前 Turn。某个并发工具先说"结束",不会让它后面的已请求调用凭空消失。

取消不会留下半截工具批次

用户取消时,调度器先停止补充新调用,但已经开始的调用仍要排空。这里确实使用 Promise.race() 等待"下一个完成的是谁",却不会拿它提前返回并遗弃其他 Promise;所有已启动工作都会走到停稳和有序提交。

尚未启动的调用则补一对合成事件:

ts 复制代码
function appendSkippedToolCall(
  session: Session, turn: number, step: number, block: ToolCallBlock,
): void {
  const callSeq = appendToolCall(session, turn, step, block)
  appendToolResult(session, turn, step, block, {
    content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
    isError: true,
    error: {
      message: 'tool call aborted before dispatch',
      info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
    },
  }, callSeq)
}

所以日志里出现 tool/call,不一定证明工具本体真的执行过。它只证明模型提出了调用,而且调度器已经为它给出最终交代;要判断是否启动,应结合 tool/result 的错误码。

这套处理多写了几条事件,却换来一个很实用的性质:无论正常完成、策略拒绝、超时还是中途取消,模型提出的每个调用都有对应结果。Session 不会留下"Assistant 发出了四个调用,历史里只回来两个"的断裂尾巴。

以后排查工具问题,我会先分清三种顺序:模型调用顺序、工具实际完成顺序、Session 提交顺序。第一种决定语义,第二种影响耗时,第三种保证回放。DeepSeek Harness 只放开中间那一种。

下一篇继续沿着工具本体往外看:为什么 Bash、模型、文件系统和子代理可以更换 Provider,而 Agent Loop 和 Consumer 不需要跟着重写。

相关推荐
dong_junshuai1 小时前
每天一个开源项目#86 ECC:245K星的 Agent 工程操作层
开源·github·agent
阿里云大数据AI技术1 小时前
Agentic Search 2.0:从单轮对话迈向企业级 AI 搜索自动驾驶Agent
人工智能·elasticsearch·agent
狂师2 小时前
阿里开源:skill-up,一款Agent Skill 评测工具!
人工智能·开源·agent
武子康2 小时前
同一套 Agent Runtime,为什么 Web、Headless 和 Python SDK 仍然不是同一个产品
人工智能·llm·agent
NingBo3 小时前
让非技术人员也能一键使用 DeepSeek Harness
ai编程·deepseek
阿里云云原生3 小时前
实战演示:利用 LoongSuite Pilot 实现 Claude Code Webhook 数据的旁路上报
agent
明月_清风3 小时前
发现一个超系统的 AI Agent 学习地图 —— Agent Atlas 推荐
人工智能·后端·agent
DigitalOcean4 小时前
AI Agent如何降本?从五层技术栈到三种推理服务
llm·agent
anyup5 小时前
DeepSeek Harness 从零上手:从认识到写出第一个插件
人工智能·openai·deepseek