DeepSeek Harness 教程 · 第21讲 替换引擎
第21讲 · 替换引擎——LLM 适配器与 FS Provider
《DeepSeek Harness 从上手到精通》系列 · 第七阶 深度定制篇
dsh 最让人震撼的设计是 "换脑不换壳":同一个壳子,今天跑 DeepSeek,明天跑自家代理;今天在本机读写文件,明天搬到云端沙箱。本讲带你亲手替换 ctx.llm 和 ctx.fs / ctx.subprocess 这两条核心 seam。
一、为什么这叫"换引擎"
第 7 讲里我们讲过 seam 是"接口 / 实现 / 消费者"三件套。dsh 内部有几十个 seam,但真正"换起来"最爽的只有两个:
ctx.llm:模型的"心脏"。替换它,agent 的"脑"就换了;ctx.fs + ctx.subprocess:agent 的"手脚"。替换它,整个执行世界搬家。
二、替换 LLM:让 dsh 接非 DeepSeek 模型
官方把 LLM 适配器放在 packages/llm/。我们要做的,是实现它的接口,挂回 ctx。
接口核心就两个方法:complete()(一次性补全)和 stream()(流式调用)。以一个"转发到 OpenAI 兼容代理"的最小适配器为例:
import { LLM, type LLMOptions } from '@deepseek-ai/dsh-llm'
export class OpenAICompat extends LLM {
static inject = ['config']
private baseUrl: string
async start() {
this.baseUrl = this.ctx.config.llm.openai_base_url
}
async *stream(prompt: LLMOptions) {
const resp = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.ctx.config.llm.api_key}`,
},
body: JSON.stringify({
model: prompt.model ?? 'gpt-4o-mini',
messages: prompt.messages,
stream: true,
}),
})
// 解析 SSE,转成 dsh 期望的事件
for await (const chunk of sseIter(resp.body!)) {
yield { type: 'delta', content: chunk.choices[0].delta.content ?? '' }
}
yield { type: 'done' }
}
}然后写一个插件把它挂上去,覆盖默认的 LLM 实现:
export function apply(ctx: Context) {
ctx.plugin(OpenAICompat)
// 把 ctx.llm 从默认 provider 切到我们的实现
ctx.on('ready', () => {
ctx.services.llm = ctx.get(OpenAICompat)
})
}启动 dsh 后,所有原本调用 LLM 的代码无感知——agent loop、工具调用、流式 UI,统统走新通道。这就是 seam 的威力。
三、替换 FS / Subprocess:执行世界搬家
第 7 讲提过:"执行世界"是个整体——ctx.fs 写文件,Bash/PTY/LSP 工具会"自动跟着"在新世界工作。官方给出了 packages/e2b/ 这个 E2B 沙箱实现做参考,我们照样画个"内存版":
import { FSProvider, SubprocessProvider } from '@deepseek-ai/dsh-fs'
// 内存文件系统(演示用,真实场景换成 E2B SDK)
export class MemoryFS extends FSProvider {
private files = new Map<string, string>()
async read(path: string) {
if (!this.files.has(path)) throw new Error(`ENOENT: ${path}`)
return { content: this.files.get(path)! }
}
async write(path: string, content: string) {
this.files.set(path, content)
return { bytes: content.length }
}
async list(dir: string) {
return [...this.files.keys()]
.filter(p => p.startsWith(dir))
.map(p => ({ path: p, kind: 'file' as const }))
}
}
export class MemorySubprocess extends SubprocessProvider {
// 简单的命令白名单实现
async run(cmd: string, args: string[]) {
if (cmd === 'echo') return { stdout: args.join(' ') + '\n', code: 0 }
return { stdout: '', code: 127, stderr: `unknown: ${cmd}` }
}
}
export function apply(ctx: Context) {
ctx.plugin(MemoryFS)
ctx.plugin(MemorySubprocess)
ctx.on('ready', () => {
ctx.services.fs = ctx.get(MemoryFS)
ctx.services.subprocess = ctx.get(MemorySubprocess)
})
}关键事实:你不需要改 任何 工具实现。Bash 工具走 ctx.subprocess、read/write 工具走 ctx.fs、LSP/PTY 也跟 subprocess 共享 PID 命名空间——切 Provider 一次,全家桶跟着切。
四、cookbook 精读:两段官方示例
官方 cookbook 里有两个姊妹篇值得反复读:
| 章节 | 必读内容 |
|---|---|
cookbook/llm-adapter/ | 完整 LLM 适配器示例:流式协议、token 计数、错误重试 |
cookbook/chat-node/ | 在编辑器里嵌入一个 Chat 节点,等价于本讲的"组装演练" |
cookbook 的示例都是跑得起来的,而不是伪代码。建议每读一段就 pnpm dev 一次,亲手试。
五、动手练习:双替换跑通
把上面两段代码合到一个插件包里,跑通下面这个剧本:
- 在配置里指定
llm.provider = openai-compat,把 API key 通过CredentialRef引入(第 19 讲); - 启动 dsh,进 Web UI,让 agent 执行:"先写一个 hello.txt,内容是 Hello, Memory!,再 cat 一下。"
- 打开 dsh 的内存 FS 调试页面(或在 session 日志里搜索
memory-fs),确认写入到了内存里; - 再让 agent 执行
echo hello from bash,看 Bash 工具是否通过MemorySubprocess走到了echo命令。
六、常见坑
- 流式协议不一致:OpenAI/Anthropic/DeepSeek 的 SSE 格式细节不同,转发时记得按 schema 严选字段;
- ctx.services 直接赋值不安全:应通过
ctx.plugin()+ 命名约定,或者使用ctx.replace()提供的辅助; - FS 路径是绝对的:切到 E2B/内存版后,本地路径不再有意义,工作目录一定要在 Provider 内部管理;
- subprocess 安全白名单:内存版仅演示,生产场景必须接 Landlock + 命令白名单(第 18 讲);
- 没重启就换 Provider:替换时要确保旧 Provider 已 dispose,否则会同时存在两份服务导致事件双发。
七、官方文档对应章节
docs/seams/llm.md、docs/seams/fs.md、docs/seams/subprocess.md;cookbook/llm-adapter/与cookbook/chat-node/;packages/llm/与packages/e2b/源码导读。
本讲内容基于 DeepSeek Harness 官方文档与项目源码整理,仅供学习参考,具体命令与配置请以官方最新版本为准。源码仓库:https://github.com/deepseek-ai/deepseek-harness
咨询 DeepSeek Harness 相关问题,请加微信:A0qingfengyuan_01
个人观点,仅供参考,如有不对之处,请多包涵,欢迎指出。本文由 AI 协助完成!
欢迎扫码关注公众号与作者微信,获取更多实用干货

