DSH Agent Loop 与工具管线:每个请求如何流转

深入 DSH Agent Loop 内部机制:Step/Turn 模型、三条瀑布链、工具并发池、压缩与会话日志不变式,附完整代码。

先说结论: DSH 每个请求走一条确定性管线:Turn 包含零到多个 Step,每个 Step = 一次模型调用 + 它触发的所有工具。三条瀑布链(agent/pre-stepagent/requesttools/pre-execute)赋予插件完整的拦截能力。工具并发用 barrier + 有界滚动池。会话日志执行”模型可见即已记录”——模型看到的一切都在追加日志里。所有注册都是可逆的 Cordis 效果,随时热卸载。

Turn 与 Step:执行模型的两个原语

理解 DSH 的执行流程,先要区分两个结构原语:

概念定义作用域
Step一次模型请求 + 它调用的全部工具原子工作单元
Turn一条外部消息触发的零到多个 Step用户可见边界
Round包含一个 Turn 的外层策略迭代(如目标 Round)策略层(非循环层)

用户发送一条消息,Agent Loop 打开一个 Turn、声明所有权、进入 Step 循环。每个 Step 调用一次模型、处理工具调用,然后进入下一个 Step 或关闭 Turn。Hook 在 Step 边界触发,不在任意点触发——这是精确控制的前提。

DeepSeek Harness GitHub 仓库

完整事件流

一个 Turn 内的全部事件,从开启到结算:

turn/start → claim → agent/pre-step (waterfall)
  → step/start → user/message → system-prompt/assemble
  → agent/request (waterfall) → llm/stream
  → assistant/chunk* → assistant/message
  → tool/call* → tools/pre-execute → tools/execute → tools/post-execute → tool/result*
  → step/end → agent/turn-stopping

星号(*)表示每个 Step 内可触发零到多次。每个箭头对应一个具体的 session event 或 waterfall dispatch。

事件参考表

事件模式用途
turn/startemit开启 Turn 边界
agent/pre-stepwaterfall在模型看到消息前改写/拒绝
step/startemit开启一轮模型调用
system-prompt/assemble协作 waterfall从注册的 section 组装系统提示词
agent/requestwaterfall替换模型配置(provider、temperature 等)
llm/streamemit流式传输开始
assistant/chunkemit一批流式 token
assistant/messageemit完整助手回复
tool/callemit一次工具调用已记录
tools/pre-executewaterfall执行前允许/拒绝/询问
tools/executewaterfall环绕分派(超时、重试、指标)
tools/post-executewaterfall接受/替换/充实/阻止结果
tool/resultemit冻结的权威结果
step/endemitStep 完成
agent/turn-stoppingemitTurn 结算决策点

三条关键瀑布链

瀑布链是拦截机制。每个监听器收到参数和一个 next() 续体。调用 next() 委托下游;不调用则短路整条链。插件通过这个机制组合行为而不互相耦合。

1. agent/pre-step — 消息改写

每个 Step 前触发。监听器可以改写模型即将看到的消息,也可以直接拒绝这个 Step。

'agent/pre-step'(
  this: Scoped<Agent>,
  payload: {
    agent: Agent;
    messages: UserMessage[];
    turn: number;
    step: number;
    signal: AbortSignal;
  },
  next: () => Promise<PreStepDecision>
): Promise<PreStepDecision>

典型场景:

  • 压缩dsh-compaction-basic):在此检测上下文压力并触发裁剪
  • 内容过滤:在模型看到前脱敏或删除敏感内容
  • 注入:仅对特定 Step 添加系统上下文

2. agent/request — 模型配置替换

在系统提示词组装之后、实际 LLM 调用之前触发。监听器可以完整替换冻结的调用配置。

'agent/request'(
  this: Scoped<Agent>,
  payload: {
    agent: Agent;
    turn: number;
    step: number;
    signal: AbortSignal;
  },
  next: () => Promise<LlmCallConfig>
): Promise<LlmCallConfig>

典型场景:

  • 模型路由:简单后续用便宜模型
  • Temperature 调节:头脑风暴 Step 提高创造力
  • Provider 容灾:透明重定向到备用供应商

3. tools/pre-execute — 权限门

每次工具分派前触发。系统中最强大的策略点。

'tools/pre-execute'(
  this: Scoped<ToolRuntime>,
  exec: ToolExecution,
  next: () => Promise<PreToolDecision>
): Promise<PreToolDecision>

典型场景:

  • 审批流:破坏性操作前询问用户
  • 沙箱化:拒绝工作区外的文件写入
  • 限流:节流昂贵的 API 调用

DSH Packages 目录结构

工具并发:Barrier + 有界滚动池

模型在一条回复中返回多个工具调用时,DSH 既不全部并行也不串行,而是用两层并发模型:

机制行为
Barrier一条回复里的所有工具调用归为一批
有界滚动池批内最多 maxParallelToolCalls 个并行
exclusive executionMode独占运行,阻塞批内其他工具
parallel executionMode正常进入滚动池

配置方式:

// Agent 配置中
{
  maxParallelToolCalls: 4,  // 最多 4 个工具并行
}

单个工具声明自己的执行模式:

ctx.tools.register({
  name: 'write_file',
  executionMode: 'exclusive',  // 独占运行
  // ...
})

ctx.tools.register({
  name: 'web_search',
  executionMode: 'parallel',   // 可与其他工具重叠
  // ...
})

exclusive 工具会让滚动池完全排空,然后独占运行,运行完再释放池。这比全局 maxParallelToolCalls: 1 更精确——只有需要原子性的工具独占,其他工具继续并行。

工具执行管线全景

单个工具调用的完整流转:

模型回复包含 tool-call block
  → Session event: tool/call(执行前记录)
  → UI pending card
  → tools/pre-execute waterfall(hooks、权限、沙箱)
    → 注册的单调守卫(deny 或 abstain)
    → ctx.approval 一次性询问(如果是 ask)
  → tools/execute waterfall(超时、重试、指标)
    → 注册的 tool execute() body
    → fs/write-intent 或 fs/edit-intent(文件变更)
  → tools/post-execute waterfall(接受、阻止、替换)
  → Registry 外层归一化(snapshot 抛出变为 isError)
  → ToolDefinition.finalizeContent(内容不变式)
  → tools/result 同步通知(冻结结果)
  → Session event: tool/result(面向模型)
  → Active-batch additionalContexts FIFO

关键洞察:tools/pre-executetools/executetools/post-execute 是三条独立的瀑布链。权限插件挂 pre-execute;指标包装器挂 execute;结果增强插件挂 post-execute。它们组合但不耦合。

压缩:不超出上下文窗口

长会话会撞上 token 上限。dsh-compaction-basic 通过两个挂钩点处理:

  1. agent/pre-step:测量上下文压力。接近上限时触发压缩周期。
  2. agent/request-error:捕获模型供应商返回的溢出错误。触发紧急压缩。

压缩周期本身:

裁剪 → 重新测量 → 摘要
  • 裁剪(Pruning):从会话日志投影中移除最不重要的消息
  • 重新测量(Remeasure):裁剪后重新计算 token 数
  • 摘要(Summary):如果仍超预算,对被裁内容生成压缩摘要

这不是一个独立系统——只是两个瀑布链监听器,用的 hook 点和任何其他插件一样。

会话日志不变式

会话日志有一条铁律:

模型可见即已记录。

任何到达模型请求的内容都必须能从追加日志中重建。运行时强制检查这个不变式。

deriveMessages() 从日志投影出模型历史。它不维护独立状态——从权威事件流派生当前对话。这意味着:

  • Fork、Resume、Replay 都从同一来源工作
  • 遥测和持久化读同一条流
  • 没有隐藏状态能泄入模型上下文
// 日志是追加式的。模型上下文是投影。
const messages = deriveMessages(sessionLog)
// messages === 模型将要看到的精确内容

新增模型可见内容要求扩展 SessionEventMap 并从日志渲染。你无法在不被记录的情况下向模型上下文偷渡内容。

Cordis 论文 GitHub

可逆的 Cordis 效果

Agent Loop 中的一切注册——工具定义、瀑布链监听器、提示词 section、模型适配器——都是可逆的 Cordis effect:

// 注册一个工具——返回清理函数
const dispose = ctx.tools.register({
  name: 'my_tool',
  execute: async (args) => { /* ... */ },
})

// 之后:干净移除
dispose()
// 下次 prompt assembly 就看不到这个工具了

热重载插件?dispose 所有效果、加载新版本、重新注册。Agent Loop 不重启——只是在下一个 Step 看到更新后的注册。

这是 DSH 的插件模型与静态配置的本质区别。插件可以在会话中途增删改行为,不重启、不丢状态。

实战示例:构建 Token 预算守卫

来看这些原语如何组合。假设你想在 Turn 超出 token 预算时停止:

import { Context } from '@deepseek-ai/dsh-core'

export function apply(ctx: Context) {
  let turnTokens = 0

  // Turn 开始时归零
  ctx.on('turn/start', () => { turnTokens = 0 })

  // 每条助手消息计入 token
  ctx.on('assistant/message', (msg) => {
    turnTokens += msg.usage?.totalTokens ?? 0
  })

  // 如果超预算,拦截下一个 Step
  ctx.on('agent/pre-step', async (payload, next) => {
    if (turnTokens > 50_000) {
      return { kind: 'stop', reason: 'token-budget-exceeded' }
    }
    return next()
  })
}

三个事件、一条瀑布链,与所有其他插件可组合。不继承、不猴子补丁、不框架耦合。

与更宏观架构的关联

Agent Loop 是执行核心,但不是全貌:

常见问题

Q:能往循环里加新的瀑布链事件吗? A:不能。事件目录由 SessionEventMap 固定。你通过监听已有事件和组合瀑布链来扩展行为,而不是添加新的循环阶段。新增模型可见内容需要扩展 session event map——这是一个需要审查的刻意变更。

Q:瀑布链监听器抛出异常会怎样? A:取决于事件的错误语义。tools/post-execute 会捕获异常并归一化为 isError 结果。agent/pre-step 会把错误传播到 Turn 驱动器。一般规则:控制执行的瀑布链传播异常;观察类瀑布链容纳异常。

Q:exclusive executionMode 和 maxParallelToolCalls 怎么交互? A:exclusive 工具会让有界池完全排空、独占运行、完成后释放池。它不占用池槽位——而是暂停整个池。这比设置 maxParallelToolCalls: 1 更强,因为它只保证与该 exclusive 工具没有重叠。

Q:压缩会丢失模型之前依赖的信息吗? A:会,这是设计如此。压缩是有损的——所以它生成摘要。会话日志仍然保留一切(追加式),但 deriveMessages() 在压缩后投影一个更小的窗口。需要保留特定上下文的插件应使用 PromptSection(prompt section 永远不会被裁剪)。

Q:会话日志真的是追加式的还是只是抽象? A:真的追加式。事件永远不会从日志中被修改或删除。压缩通过改变 deriveMessages() 从日志投影的方式来工作,而不是修改日志本身。Fork 用父日志的前缀创建新日志。这让回放、遥测导出和调试完全确定性。