DSH Agent Loop 与工具管线:每个请求如何流转
深入 DSH Agent Loop 内部机制:Step/Turn 模型、三条瀑布链、工具并发池、压缩与会话日志不变式,附完整代码。
先说结论: DSH 每个请求走一条确定性管线:Turn 包含零到多个 Step,每个 Step = 一次模型调用 + 它触发的所有工具。三条瀑布链(
agent/pre-step、agent/request、tools/pre-execute)赋予插件完整的拦截能力。工具并发用 barrier + 有界滚动池。会话日志执行”模型可见即已记录”——模型看到的一切都在追加日志里。所有注册都是可逆的 Cordis 效果,随时热卸载。
Turn 与 Step:执行模型的两个原语
理解 DSH 的执行流程,先要区分两个结构原语:
| 概念 | 定义 | 作用域 |
|---|---|---|
| Step | 一次模型请求 + 它调用的全部工具 | 原子工作单元 |
| Turn | 一条外部消息触发的零到多个 Step | 用户可见边界 |
| Round | 包含一个 Turn 的外层策略迭代(如目标 Round) | 策略层(非循环层) |
用户发送一条消息,Agent Loop 打开一个 Turn、声明所有权、进入 Step 循环。每个 Step 调用一次模型、处理工具调用,然后进入下一个 Step 或关闭 Turn。Hook 在 Step 边界触发,不在任意点触发——这是精确控制的前提。

完整事件流
一个 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/start | emit | 开启 Turn 边界 |
agent/pre-step | waterfall | 在模型看到消息前改写/拒绝 |
step/start | emit | 开启一轮模型调用 |
system-prompt/assemble | 协作 waterfall | 从注册的 section 组装系统提示词 |
agent/request | waterfall | 替换模型配置(provider、temperature 等) |
llm/stream | emit | 流式传输开始 |
assistant/chunk | emit | 一批流式 token |
assistant/message | emit | 完整助手回复 |
tool/call | emit | 一次工具调用已记录 |
tools/pre-execute | waterfall | 执行前允许/拒绝/询问 |
tools/execute | waterfall | 环绕分派(超时、重试、指标) |
tools/post-execute | waterfall | 接受/替换/充实/阻止结果 |
tool/result | emit | 冻结的权威结果 |
step/end | emit | Step 完成 |
agent/turn-stopping | emit | Turn 结算决策点 |
三条关键瀑布链
瀑布链是拦截机制。每个监听器收到参数和一个 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 调用

工具并发: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-execute、tools/execute、tools/post-execute 是三条独立的瀑布链。权限插件挂 pre-execute;指标包装器挂 execute;结果增强插件挂 post-execute。它们组合但不耦合。
压缩:不超出上下文窗口
长会话会撞上 token 上限。dsh-compaction-basic 通过两个挂钩点处理:
agent/pre-step:测量上下文压力。接近上限时触发压缩周期。agent/request-error:捕获模型供应商返回的溢出错误。触发紧急压缩。
压缩周期本身:
裁剪 → 重新测量 → 摘要
- 裁剪(Pruning):从会话日志投影中移除最不重要的消息
- 重新测量(Remeasure):裁剪后重新计算 token 数
- 摘要(Summary):如果仍超预算,对被裁内容生成压缩摘要
这不是一个独立系统——只是两个瀑布链监听器,用的 hook 点和任何其他插件一样。
会话日志不变式
会话日志有一条铁律:
模型可见即已记录。
任何到达模型请求的内容都必须能从追加日志中重建。运行时强制检查这个不变式。
deriveMessages() 从日志投影出模型历史。它不维护独立状态——从权威事件流派生当前对话。这意味着:
- Fork、Resume、Replay 都从同一来源工作
- 遥测和持久化读同一条流
- 没有隐藏状态能泄入模型上下文
// 日志是追加式的。模型上下文是投影。
const messages = deriveMessages(sessionLog)
// messages === 模型将要看到的精确内容
新增模型可见内容要求扩展 SessionEventMap 并从日志渲染。你无法在不被记录的情况下向模型上下文偷渡内容。

可逆的 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 是执行核心,但不是全貌:
- Capability Seams(能力接缝) 解释了让这些 hook 可替换的 Service Definition / Provider / Consumer 三角色模式
- DeepSeek Harness 开发者预览 覆盖了完整系统架构和包依赖图
- GitHub 仓库 包含所有源码,MIT 协议
常见问题
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 用父日志的前缀创建新日志。这让回放、遥测导出和调试完全确定性。


