# 第 4 章:插件开发实战 > 本章目标:从零写一个**真实可用的 host 插件**——通过 `agent/request` 扩展点自动调节推理档位。这是一个提速插件的完整拆解,所有代码可运行、可测试。 ## TL;DR(本章核心,30 秒版) 1. **核心问题**:dsh 每次工具调用前模型都重新思考,50 步任务 90%+ 时间在思考——降档是最快提速 2. **架构三板斧**:纯函数(决策逻辑,零依赖可单测)→ 插件主体(接入 waterfall)→ 实机验证(日志证明注入发生) 3. **`agent/request` waterfall**:每次模型请求前触发,监听者返回值传给下一个监听者,实现"保留原配置 + 覆盖某字段" 4. **`next()` 是 Promise,必须 await**:不 await 直接 spread 会得到空对象,provider/model 丢失报错 5. **开发纪律**:先找扩展点(90% 行为有官方钩子)、逻辑抽纯函数、实机验证不能省
本章导航 - [4.1 我们要做什么](#41-我们要做什么) - [4.2 项目骨架](#42-项目骨架) - [4.3 纯函数:决策逻辑(零依赖,可单测)](#43-纯函数决策逻辑零依赖可单测) - [4.4 插件主体:接入 `agent/request` waterfall](#44-插件主体接入-agentrequest-waterfall) - [4.5 测试](#45-测试) - [4.6 给新手的三条开发纪律](#46-给新手的三条开发纪律)
## 4.1 我们要做什么 **问题**:dsh 在每次工具调用前模型都会重新思考(`reasoning_effort`)。一个 50 步工具链任务,"思考"占 90%+ 墙钟时间。 **方案**:一个 host 插件,监听 `agent/request` waterfall,根据当前步骤最近的工具调用,把简单轮次的 `reasoning_effort` 从 `high` 降到 `low`。 ## 4.2 项目骨架 ```text dsh-speed-plugin/ ├── package.json # host 插件声明 ├── tsconfig.json ├── src/ │ ├── effort-decision.ts # 纯函数:决策逻辑(零依赖,可单测) │ └── index.ts # apply(ctx):接入扩展点 └── tests/ └── effort-decision.spec.ts ``` `package.json` 关键字段: ```json { "name": "dsh-speed-plugin", "type": "module", "main": "src/index.ts", "exports": { ".": { "types": "./src/index.ts", "default": "./src/index.ts" } }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1", "@deepseek-ai/dsh-agent": "^0.1.0-rc.6" } } ``` > ⚠️ 依赖版本务必用 `^0.1.0-rc.6` 线——rc.1 线的 npm 依赖链是断的(见第 3 章常见坑)。 ## 4.3 纯函数:决策逻辑(零依赖,可单测) `src/effort-decision.ts`: ```ts export type EffortId = 'low' | 'high' | 'max' export interface ToolCallSample { name: string // 工具名,如 'write'、'read'、'bash' argsSize: number // 参数大小(字符数) } export interface EffortDecisionInput { recentCalls: readonly ToolCallSample[] selected: EffortId // 用户基线档 allowDowngrade: boolean allowUpgrade: boolean } const SIMPLE_TOOL_RE = /^(fs|bash|terminal|read|write|grep|glob|edit|ls|cat|rm|cp|touch|mkdir|pwd)/i const HEAVY_ARGS = 800 export function decideEffort(input: EffortDecisionInput): EffortId { const { recentCalls, selected, allowDowngrade, allowUpgrade } = input if (recentCalls.length === 0) return selected // 全新提示:保持基线 const ratio = recentCalls.filter(c => SIMPLE_TOOL_RE.test(c.name) && c.argsSize < HEAVY_ARGS, ).length / recentCalls.length const heaviest = recentCalls.reduce((m, c) => Math.max(m, c.argsSize), 0) if (ratio >= 0.75 && allowDowngrade) return 'low' if (heaviest >= HEAVY_ARGS * 4 && allowUpgrade) return 'max' if (ratio < 0.75) return allowUpgrade ? 'high' : selected return selected } ``` **为什么拆成纯函数**:决策逻辑与 dsh 运行时解耦——单元测试零依赖、毫秒级、覆盖所有分支,实机只需要验证"注入是否真的发生"。 ## 4.4 插件主体:接入 `agent/request` waterfall `src/index.ts`: ```ts import type { Context } from '@deepseek-ai/cordis' import { decideEffort, type ToolCallSample } from './effort-decision.ts' export interface SpeedPluginConfig { enabled: boolean allowDowngrade: boolean allowUpgrade: boolean baseline: 'low' | 'high' | 'max' } export const DEFAULT_CONFIG: SpeedPluginConfig = { enabled: true, allowDowngrade: true, allowUpgrade: false, baseline: 'high', } const WINDOW = 8 function recentToolCalls(agent: unknown): ToolCallSample[] { const events = (agent as { session?: { events?: readonly unknown[] } }).session?.events ?? [] const out: ToolCallSample[] = [] for (let i = events.length - 1; i >= 0 && out.length < WINDOW; i--) { const e = events[i] as { type?: string; data?: { name?: string; arguments?: unknown } } | undefined if (e?.type !== 'tool/call') continue out.push({ name: e.data?.name ?? 'tool', argsSize: typeof e.data?.arguments === 'string' ? e.data.arguments.length : 0, }) } return out.reverse() } export function apply(ctx: Context, config: SpeedPluginConfig = DEFAULT_CONFIG): void { if (!config.enabled) return // 边界适配:npm 包未 re-export 官方事件类型增强,这里放宽签名(第 3 章常见坑) const on = ctx.on as unknown as ( event: string, handler: (payload: Record, next: () => unknown) => unknown | Promise, ) => void on('agent/request', async (payload, next) => { const seed = await next() as { reasoningEffort?: unknown } // ⚠️ 必须 await! const calls = recentToolCalls(payload.agent) const effort = decideEffort({ recentCalls: calls, selected: config.baseline, allowDowngrade: config.allowDowngrade, allowUpgrade: config.allowUpgrade, }) console.log(`[speed-plugin] calls=${JSON.stringify(calls)} => reasoningEffort=${effort}`) return { ...seed, reasoningEffort: effort } }) } ``` **三个关键点**(都是真实踩过的坑): 1. **`next()` 是 Promise**:`await next()` 拿到当前配置;不 await 直接 spread 会得到空对象 → provider/model 丢失 → 报错。 2. **waterfall 语义**:监听者的**返回值**传给下一个监听者/最终请求。返回 `{...seed, reasoningEffort}` 就是"保留原配置 + 覆盖推理档位"。 3. **`agent/request` 每步都触发**:`agent-loop` 的 `buildRequest` 在每一步都会走这个 waterfall——所以动态决策天然按步生效。 ## 4.5 测试 **单元测试**(纯函数,零依赖): ```ts import { describe, expect, it } from 'vitest' import { decideEffort } from '../src/effort-decision.ts' it('downgrades to low for simple tool chains', () => { expect(decideEffort({ recentCalls: [{ name: 'write', argsSize: 40 }], selected: 'high', allowDowngrade: true, allowUpgrade: true, })).toBe('low') }) // ... 更多分支:全新提示保持基线 / 禁用降档 / 超大载荷升 max / 混合工具升 high ``` **实机验证**(关键——证明"注入真的发生"): 挂载插件(第 3 章方法)→ 重启 `dsh web` → 发一个创建文件的任务 → 观察 dsh 进程日志: ```text [speed-plugin] agent/request: calls=[] => reasoningEffort=high [speed-plugin] agent/request: calls=[{"name":"write",…}] => reasoningEffort=low ``` 第一轮无工具调用 → 保持基线 `high`;检测到 `write` 工具 → 下一轮降为 `low`。**注入链路完整工作。** > 完整可运行代码:参考本章各节代码片段组合即可运行。 ## 4.6 给新手的三条开发纪律 1. **先找扩展点**:要改的行为 90% 有官方钩子(`agent/request`、`settings`、`conversationEvents`、`slots`)——不要 fork 核心。 2. **逻辑抽纯函数**:决策/计算逻辑与 dsh 解耦 → 单测毫秒级、覆盖全分支;实机只需验证"注入发生"。 3. **实机验证不能省**:单测证明逻辑,实机日志证明接线——两个都过才算完成。 > 📚 **官方 cookbook 延伸阅读**(2026-08 官方新增,官方仓库 `docs/cookbook/`):`adding-a-package.md`(如何加包)、`adding-a-tool.md`(如何加工具)、`adding-a-conversation-node.md`(加对话节点)、`adding-an-llm-adapter.md`(写 LLM 适配器)、`adding-a-vendored-package.md`(vendor 包)、`extension-cookbook.md`(扩展点合集)。本章走的是"最小提速插件"路径,官方 cookbook 覆盖更多扩展点类型,进阶时对照读。 --- ## 动手练习(检验你是否真懂了) 1. **理解题**:不看原文,说出这个提速插件的三层架构(纯函数层 / 插件主体层 / 测试层)各自负责什么 > 自查:参考本章 4.2-4.5 节的文件结构 2. **理解题**:解释为什么 `decideEffort` 要设计成纯函数而不是直接在 `apply(ctx)` 里写逻辑。如果决策逻辑依赖了 `ctx.session`,还能单测吗? > 自查:参考本章 4.3 节"为什么拆成纯函数"段落 3. **动手题**:给 `decideEffort` 写一个新的测试用例:当 `recentCalls` 里有 3 个简单工具 + 1 个超大参数(`argsSize = 5000`)时,应该返回什么档位?写出测试代码并运行 > 自查:参考本章 4.5 节单元测试示例,预期结果取决于 `allowUpgrade` 配置 4. **动手题**:在 `apply(ctx)` 里,如果把 `await next()` 改成 `const seed = next()`(不 await),会发生什么?写出你的推理,然后在实机中验证 > 自查:参考本章 4.4 节"三个关键点"第 1 条 5. **动手题**:假设你要写一个类似的插件,但改为根据"当前会话的工具调用总次数"来决定档位(超过 20 次自动降为 low),写出纯函数签名和核心逻辑 > 自查:参考本章 4.3 节纯函数设计模式,关键是输入输出类型定义 6. **思考题**:`agent/request` waterfall 可以有多个监听者。如果提速插件和另一个插件都监听了 `agent/request`,谁先执行?返回值怎么传递? > 自查:参考本章 4.4 节"waterfall 语义"段落 + 第 3 章 3.4 节扩展点说明 ## 常见疑问 FAQ **Q1:`next()` 返回的 seed 里到底包含什么字段?** seed 是当前 waterfall 链上游累积的请求配置,通常包含 `provider`、`model`、`reasoningEffort`、`tools` 等字段。你的监听者拿到 seed 后,spread 覆盖想改的字段,其余原样传递。具体字段以官方 `agent-loop` 包的类型定义为准(rc 阶段可能变动)。 **Q2:为什么 npm 包的类型签名要"放宽"?不能直接用官方类型吗?** 因为 npm 发布的 `@deepseek-ai/dsh-agent` 等包没有 re-export 内部的事件类型增强(`Events` 接口没有被 module augmentation 扩展)。直接用 `ctx.on('agent/request', ...)` 会报类型错误。解决方案是在边界用 `as unknown as` 转换,这是 rc 阶段的临时方案,正式版可能修复。 **Q3:我的插件需要在每个工具调用后都触发决策,`agent/request` 够吗?** 够。`agent/request` 在每次模型请求前触发(包括工具调用后的下一轮请求)。你只需要在监听者里读取最近的工具调用历史(从 `payload.agent.session.events` 里倒序找 `tool/call` 事件),就能做按步决策。 **Q4:纯函数测试通过了,实机验证也通过了,但用户反馈"有时候没效果",可能是什么原因?** 几种可能:① 用户的 `settings.yaml` 里 `reasoningEffort` 是 `low`,降档无效果(已经最低了);② 用户的任务全是简单工具,本来就快,感知不到差异;③ waterfall 里有其他插件覆盖了你的返回值。建议加日志记录每步的输入/输出档位。 **Q5:我想给插件加一个用户可配置的开关(设置页里能开关),怎么做?** 用 `settings` 服务注册一个命名空间(如 `speed-plugin`),声明配置项(enabled、baseline 等)。dsh 的设置页会自动渲染表单,用户修改后通过 `ctx.get` 读取。具体 API 参考官方 `dsh-settings` 包文档。 **Q6:`recentToolCalls` 函数里为什么要 `reverse()`?** 因为从 events 数组末尾往前遍历(取最近的 N 个),结果是倒序的(最新的在前)。reverse 后恢复时间正序(最旧的在前),和实际调用顺序一致,方便决策逻辑按"最近窗口"理解。 --- **下一章**:[第 5 章:实战案例](./05-cases.md)(规划中)—— Git 面板、HTML 草稿预览、提速插件。