返回《DeepSeek Harness 架构设计指南》

Waterfall 事件链

IT周瑜

第 2 章留下了一个问题。事件有四种分发模式,为什么 dsh 最倚重其中一种。答案藏在需求里。一个 Agent 运行时里大量的扩展需求,拆开看都是中间件需求。权限插件想在请求发出去之前检查它,日志插件想给请求打标,压缩插件想在上下文超长时裁剪它。这些插件彼此不认识,却要依次作用于同一个请求。Cordis 用 waterfall 模式精确表达了这种协作。

四种模式先摆在一起看。emit 最简单,广播一个事件,监听者按注册顺序依次观察,不改变任何东西。parallel 让所有监听者并发收到事件,适合互相独立的副作用,比如刷新几个缓存。serial 按注册顺序逐个执行监听者,前一个完成后一个才开始,并且可以携带值。waterfall 在 serial 的基础上更进一步,监听者不仅按顺序执行,还能层层加工一个值,像工厂流水线,也像 HTTP 框架里的洋葱圈中间件。

waterfall 的调用约定值得细看。监听者收到参数列表,最后一项是 next 函数。调用 next(),你就把手中可能已经改写的值交给链条的下一环,next() 的返回值带着后续所有环节的结果回来。不调用 next() 直接返回,链条就在你这里终止。这两个动作对应中间件的两种权力,加工与拦截。

请求 request 插件 A 权限检查 插件 B 改写内容 next() 插件 C 记录遥测 核心 执行 返回值沿原路传回,每一环都能加工结果 某一环不调 next(),链条就此截断,请求到不了核心
图 3-1 请求沿链条传递给核心,返回值沿原路传回,任何一环都能改写或截断

在 dsh 的实际事件表里,waterfall 承担着几条最重要的管道。agent/request 包裹每一次发往模型的请求,权限与策略插件在这里行动。agent/pre-step 在一个执行步骤开始前决定模型看到什么,监听者可以改写认领到的消息,也可以直接拒绝。llm/stream 包裹模型返回的流式响应。三个 tools/* 事件构成工具执行管道,第 7 章细讲。这些事件有一个共同的硬性约定,监听者必须调用 next() 完成委托。忘了调用,整条链就停在你手里,这是 waterfall 模式最常见的错误,dsh 的文档反复强调它。

举一个具体的例子来体会链条的形态。假设一次模型请求经过三个插件。插件 A 做权限检查,它核对请求里的目标没有越界,把请求原样交给下一环。插件 B 做内容改写,它给系统提示追加了一段项目背景,再交给下一环。插件 C 做遥测记录,它记下这次请求的规模,照常放行。请求到达核心,被真正发往模型服务,返回值再沿 C、B、A 的顺序传回。任何一环都可以在回程加工结果,比如插件 C 顺手在结果上记录 token 用量。如果插件 A 核对失败,它不调 next(),直接返回一个拒绝,后面的插件和核心都不会被触及。

一个容易忽略的细节是注册顺序的含义。链条上谁在前谁在后,取决于监听者注册的先后,而注册顺序又取决于插件在组合配置里的排列。这带来了一个工程上的纪律。dsh 把循环本身写得极薄,所有策略都挂在外围事件上,循环代码因此很少需要改动。当有人问起某个请求为什么被改写了,排查路径是明确的。先看插件树的排列顺序,再逐个看 waterfall 监听者做了什么。

也有不适合 waterfall 的场合。agent/turn-stopping 用的是 serial,监听者依次投票决定一个回合是否该停,没有 next(),也没有值需要加工。纯粹的观察,比如界面刷新,用 emit。互不等待的副作用用 parallel。模式的选择本身是一种设计表达,dsh 的事件表公开列出每个事件用哪种模式,读那张表就能理解每个扩展点允许做什么。

选事件时还有第二个维度的决策,耐久还是即时。dsh 把全部事件分在三个域里。会话事件是耐久事实,进第 4 章那份日志,turnstep、消息、工具调用结果都在此列,判据是这条事实要不要活过一次重启。Agent 事件携带一个活的 Agent 实例,用来观察或拦截正在进行的工作。能力事件贴在各个接缝上,fstoolstelemetry 各有自己的事件族,策略挂在这里,不必触碰执行循环。三个域对应三种意图,记事实、管飞行中的工作、给能力加策略。dsh 的架构文档说,给改动挑事件域是大多数设计的第一步,这句话是经验的浓缩。

到这里,插件的装载和协作机制都齐了。但一个 Agent 运行时最要紧的资产还不是这些,是它记录下来的对话过程。下一章讲会话日志,dsh 把它当作整个系统唯一的事实来源。