当用户的请求进来时,Agent 系统如何把模型推理、工具调用、安全策略这些能力有机地组合起来?

实际做 Agent 开发时,你迟早会遇到一个核心问题:当你想给系统增加一个新能力——比如换一套模型适配器、加一道工具执行前的安全审批、或者注册一个自定义工具时——传统框架往往要求你深入源码、修改核心类、再祈祷合并请求能被接受。DeepSeek Harness(简称 dsh)的做法完全不同:连 Agent 循环本身都是插件,你写的插件和它内置的插件享有完全平等的待遇。

本文基于 dsh v0.1.0-rc.8,讲清楚 4 件事:

  1. Harness 的四种插件类型分别解决什么问题,你该如何选择;

  2. 如何从零写一个 Service Provider 插件,替换系统的核心能力;

  3. 如何利用 waterfall 事件拦截机制,在不改源码的情况下注入安全策略;

  4. 如何用 defineTool 注册类型安全的自定义工具,并让它被模型自然调用。


1,为什么 Harness 的插件化设计让扩展变得简单

先说结论:Harness 没有特权内核——模型适配器是插件,工具注册表是插件,Agent 循环也是插件。扩展等于写一个新插件挂载到 Cordis 容器,不需要 fork 一行源码。

你回想一下在传统框架里扩展功能的经历。想换一套 LLM 适配器?去找 ChatModel 的抽象类,看看预留的接口够不够表达你的需求。想加一道工具执行前的审批?去找 ToolExecutor 的源码,看看有没有 Hook 可以挂。最头疼的是——当你发现 Agent 的决策循环不够灵活时,你几乎必须 fork 整个项目,因为循环逻辑是写在"核心引擎"里的。

Harness 的设计哲学是:没有特权内核。这意味着你可以替换 Agent 的核心驱动逻辑,而不需要 fork 任何代码——只需写一个新插件注册到 ctx.agents

这里容易产生一个误解:"插件化"听起来跟"模块化"差不多,不就是代码分块吗? 其实两者的层次完全不同。模块化是把代码拆成文件,插件化是把运行时行为拆成可独立加载、卸载、替换的服务。模块化解决的是"代码好读",插件化解决的是"运行时行为好换"。Harness 的插件不仅能热插拔,卸载时还会自动回滚所有副作用——这是模块化远做不到的。

  • • 解耦:Consumer 依赖抽象的 ctx.<key>,不依赖具体实现;

  • • 可逆:所有注册通过 ctx.effect() 完成,卸载时副作用自动回滚;

  • • 组合:Profile + Bundle + cordis.patch.yml 按层叠加,无需改源码;

  • • 类型安全:TypeScript 6.0 strict 模式,事件名和参数类型通过 declaration merging 声明。


2,四种插件类型与选择策略

Harness 的插件生态不是一锅粥,而是有清晰的分工。你写插件之前,首先要判断自己要做的事属于哪一类。选错了类型,就像把螺丝刀当锤子用——不是不能用,是效率低。

2.1 Service Provider 插件:替换底层能力

先说结论:当你想替换 Harness 的某个底层能力实现时——比如换模型、换文件系统、换沙箱——写 Service Provider 插件。

Harness 通过 Capability Seam 模式把接口声明和实现分离。ctx.llmctx.fsctx.shell 等都是 Seam。框架内置了一些 Provider(如 llm-deepseekfs-local),但你可以完全替换它们。

举个例子:你想让 Harness 使用你自己的内部模型网关,而不是直接调用 DeepSeek API。你需要:

  1. 实现 LLMService 接口(声明在 dsh-llm 包中);

  2. 在你的插件里通过 super(ctx, 'llm') 把自己注册为 ctx.llm

  3. 在 cordis.patch.yml 里让你的插件覆盖默认的 llm-deepseek

这里容易产生一个误解:"我直接改配置文件里的 API key 不就行了,为什么要写插件?" 如果你的模型网关和 OpenAI 接口完全兼容,确实改配置就够了。但如果你的网关有自定义认证方式、请求格式、或者流式响应协议不同——你就必须写 Provider 插件来适配。

  • • 典型场景:接入内部模型网关、替换本地文件系统为远程 E2B 沙箱、接入自研的搜索服务;

  • • 核心机制:实现接口 + super(ctx, 'key') 注册 + inject 声明依赖;

  • • 卸载行为:Service 自动从 ctx 移除,Consumer 的后续调用会触发缺失依赖错误。

2.2 Event Interceptor 插件:拦截与增强

先说结论:当你想在 Agent 运行的关键路径上"加料"——比如审批、日志、限流、改写请求——写 Event Interceptor 插件,挂载到 waterfall 事件。

Harness 的十种核心事件全部是 waterfall 类型。waterfall 的语义是 around-中间件:每个监听器收到 (args, next),调用 next() 委托给下一个,不调用则短路整个链条。

最常用的拦截点:

事件

拦截时机

典型用途

agent/pre-step

每步开始前

改写系统提示词、注入上下文

agent/request

LLM 请求发送前

限流、记录请求日志、修改参数

tools/pre-execute

工具执行前

安全审批

、权限检查、返回 deny 短路

tools/post-execute

工具执行后

结果脱敏、添加审计日志、替换返回值

llm/stream

流式响应逐 chunk

实时过滤敏感内容、统计 token

关键设计:tools/pre-execute 是阻断式拦截的黄金挂载点。返回 { verdict: 'deny', reason: '...' } 即可阻止工具执行,模型会收到一个 error 类型的结果。

2.3 Tool Plugin 插件:让模型调用你的代码

先说结论:当你想让模型拥有某个新能力——比如查询数据库、调用内部 API、操作某个特定软件——写 Tool Plugin,用 defineTool 定义工具。

这是最常见的插件类型。Tool Plugin 本质上就是给模型"教"一个新函数。 Harness 的 defineTool 做了六件事:Schema 编译、栈安全检测、参数类型推断、返回值推断、自动验证、软验证回退。

2.4 Agent Loop 插件:替换核心驱动

先说结论:当你对默认的 ReAct 循环不满意——比如想换成 Plan-and-Execute、Multi-Agent 协作、或者带反思的循环——写 Agent Loop 插件,实现 Agent 接口。

这是最高阶的插件类型。默认的 ReactLoopAgent 只是 dsh-agent-loop 这个插件提供的实现。你可以完全替换它:

classMyCustomAgentimplementsAgent {
readonlyinbox: Inbox
readonlyscope: Scope
readonlyctx: Context

followup(input: UserMessage): void { /* 你的实现 */ }
steer(input: UserMessage): void { /* 你的实现 */ }
inject(input: UserMessage): void { /* 你的实现 */ }
}

然后注册到 ctx.agents 的 AgentFactory 中。Consumer 代码(如 ctx.agents.create())完全不需要改动。


3,实战:写一个 Service Provider 插件

假设你的团队有一个内部模型网关,接口格式和 DeepSeek 类似,但认证方式是自定义的 X-Internal-Token 头。你需要写一个 llm-internal 插件来接入它。

3.1 项目结构

llm-internal/
├── package.json
├── tsconfig.json
└── src/
    └── index.ts

3.2 核心实现

// src/index.ts
import { Context, Service } from'cordis'
import { LLMService, ChatRequest, ChatStreamChunk } from'dsh-llm'

classInternalLLMServiceextendsServiceimplementsLLMService {
static inject = ['config'] asconst

constructor(ctx: Context, config: InternalLLMConfig) {
super(ctx, 'llm')
this.config = config
  }

async *chat(request: ChatRequest): AsyncGenerator<ChatStreamChunk> {
const response = awaitfetch(this.config.gatewayUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Internal-Token': this.config.token,
      },
body: JSON.stringify(this.transformRequest(request)),
    })

forawait (const chunk ofthis.parseStream(response.body!)) {
yield chunk
    }
  }

privatetransformRequest(req: ChatRequest): object {
// 把 Harness 的请求格式转为你内部网关的格式
return { /* ... */ }
  }

privateasync *parseStream(body: ReadableStream): AsyncGenerator<ChatStreamChunk> {
// 解析 SSE 流,产出 Harness 标准的 chunk 格式
/* ... */
  }
}

exportinterfaceInternalLLMConfig {
gatewayUrl: string
token: string
}

exportfunctionapply(ctx: Context, config: InternalLLMConfig) {
  ctx.plugin(InternalLLMService, config)
}

关键设计点:

  • • 继承 Service:通过 super(ctx, 'llm') 把自己挂载到 ctx.llm,覆盖之前的 Provider;

  • • static inject:声明依赖 config,Cordis 会确保配置服务先就绪;

  • • 声明式卸载:Service 基类已经帮你处理了卸载逻辑——插件被移除时,ctx.llm 自动回滚到之前的状态。

3.3 配置文件覆盖

在你的项目根目录创建 cordis.patch.yml

plugins:
llm-deepseek:
disabled:true
llm-internal:
gatewayUrl:'https://gateway.mycompany.com/v1/chat'
token:${INTERNAL_TOKEN}

这里容易产生一个误解:"我把默认插件 disabled 了,那其他依赖 ctx.llm 的插件会不会崩? 不会。因为 llm-internal 也注册到了 ctx.llm 这个 key 上,Consumer 代码只认 ctx.llm 这个接口,不认背后是谁提供的。


4,实战:写一个 Event Interceptor 插件

假设你需要在工具执行前加一道审批:所有涉及文件写入的操作必须经过用户确认。

4.1 实现审批拦截器

// src/index.ts
import { Context } from'cordis'

exportconst name = 'tool-approval'
exportconst inject = ['tools'] asconst

exportfunctionapply(ctx: Context) {
  ctx.on('tools/pre-execute', async (call, next) => {
// 只对写操作进行拦截
if (!isWriteOperation(call.tool.name)) {
returnnext()  // 放行,不拦截
    }

// 调用你的审批 UI 或发送通知
const approved = awaitrequestUserApproval({
tool: call.tool.name,
args: call.args,
reason: '该操作将修改文件系统',
    })

if (!approved) {
// 不调用 next(),直接短路,返回拒绝结果
return {
verdict: 'deny'asconst,
reason: '用户拒绝了该操作',
      }
    }

// 用户同意,继续执行链
returnnext()
  })
}

functionisWriteOperation(toolName: string): boolean {
const writeTools = ['write_file', 'edit_file', 'bash', 'shell']
return writeTools.includes(toolName)
}

asyncfunctionrequestUserApproval(details: object): Promise<boolean> {
// 你的审批逻辑:弹窗、发 Slack、写数据库……
returntrue
}

4.2 关键机制解析

waterfall 事件的 around-中间件模式是 Harness 策略拦截的核心。理解这三点:

  • • 调用 next():把控制权交给下一个监听器,最终到达工具执行;

  • • 不调用 next():短路整个链条,你的返回值就是最终结果;

  • • 修改参数后调用 next():类似于 Koa 中间件,可以改写请求再继续。

这里容易产生一个误解:"如果多个插件都监听了 pre-execute,它们的执行顺序是什么? Cordis 按注册顺序调用。先注册的先收到事件,先处理。如果第一个插件就 deny 了,后面的插件不会被执行。这就是 waterfall 的"瀑布"语义——水从上到下流,遇到堤坝就停止。


5,实战:写一个 Tool Plugin 插件

假设你想让模型能查询你公司的内部知识库。你需要定义一个 search_knowledge_base 工具。

5.1 用 defineTool 定义工具

// src/index.ts
import { Context } from'cordis'
import { defineTool } from'dsh-tools'

exportconst name = 'knowledge-base'
exportconst inject = ['llm'] asconst

const searchKB = defineTool({
name: 'search_knowledge_base',
description: '查询公司内部知识库,返回与问题相关的文档片段',
parameters: {
query: {
type: 'string',
description: '搜索关键词或问题',
required: true,
    },
topK: {
type: 'number',
description: '返回结果数量',
default: 5,
    },
  },
output: {
type: 'object',
properties: {
results: {
type: 'array',
items: {
type: 'object',
properties: {
title: { type: 'string' },
snippet: { type: 'string' },
source: { type: 'string' },
          },
        },
      },
    },
  },
asyncexecute(args) {
// args.query 类型是 string(required)
// args.topK 类型是 number(有默认值)
const response = awaitfetch('https://kb.internal.com/api/search', {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.KB_TOKEN}` },
body: JSON.stringify({
q: args.query,
limit: args.topK,
      }),
    })

const data = await response.json()
return { results: data.hits }
  },
})

exportfunctionapply(ctx: Context) {
  ctx.tools.register(searchKB)
}

5.2 defineTool 的六个自动能力

  • • Schema 编译:友好的 DSL 自动转成 JSON Schema;

  • • 参数推断args.query 被推断为 stringargs.topK 被推断为 number

  • • 自动验证execute 执行前,参数会自动校验,类型不匹配直接抛错;

  • • 软验证presentCall / presentResult 在 replay 时软验证,失败不抛错;

  • • 栈安全:Schema 编译是迭代的,不担心循环引用爆栈;

  • • 输出推断:返回值类型从 output schema 自动推断,类型安全贯穿始终。

5.3 让模型知道这个工具

工具注册到 ctx.tools 后,Harness 会自动把它加入模型的 tools 列表。你不需要手动告诉模型"你可以调用这个函数"。但有一个细节要注意:

这里容易产生一个误解:"我注册了工具,模型就一定会用吗? 不一定。模型是否调用工具取决于 description 的质量。description 是模型判断"什么时候该用这个工具"的唯一依据。写描述时要像写给另一个工程师看的文档:说清楚什么场景下该用、输入是什么、输出是什么。


6,配置文件加载与调试技巧

6.1 插件加载的三层叠加

Harness 的插件配置不是单文件,而是三层叠加:

层级

文件/来源

作用

Preset

web

headless 等内置模板

提供基础插件组合

Bundle

项目的 package.json + 插件依赖

安装第三方插件

Patch

cordis.patch.yml

覆盖配置、禁用插件、添加参数

加载顺序是 Preset → Bundle → Patch,后面的覆盖前面的。这意味着你可以在 cordis.patch.yml 里只做"增量修改",不需要复制整个配置文件。

6.2 调试插件的实用技巧

  • • 日志输出:在插件里用 ctx.logger.info('...'),日志会按 Cordis 的命名空间组织;

  • • 热重载:开发时用 dsh --profile dev 模式,修改源码后自动重载;

  • • 依赖检查:启动时如果 inject 的依赖缺失,Cordis 会在控制台打印清晰的错误,告诉你哪个插件缺少哪个服务;

  • • 卸载测试:写单元测试时,主动 ctx.dispose() 你的插件,验证所有副作用是否被正确回滚。


7,插件开发架构全景图

把前面的组件拼起来, Harness 的插件开发全景如下:

图片

每层只跟相邻层交互,跨层通信必须通过标准协议。配置文件通过 Cordis 的组合机制加载插件树;插件运行时层在 ctx 容器中协作,通过类型化事件通信;基础设施层提供进程隔离、持久化和模型推理能力。

这里容易产生一个误解:"Service Provider 插件和 Tool Plugin 有什么区别?不都是给系统增加能力吗? 区别在于作用域和抽象层次。Service Provider 替换的是 ctx.<key> 这个抽象接口的实现(如换模型、换文件系统),影响的是整个框架的底层行为;Tool Plugin 是给模型暴露一个新函数,影响的是模型可见的工具集。类比一下:Service Provider 像是给操作系统换驱动,Tool Plugin 像是给应用程序装一个新软件。


总结

回头看 Harness 的插件开发思路,它的分层职责非常清晰:

插件类型

解决的问题

关键手段

Service Provider

底层能力如何替换

实现接口 + super(ctx, 'key') + 配置覆盖

Event Interceptor

运行时行为如何拦截

waterfall

 事件 + next() 委托/短路

Tool Plugin

模型如何获得新能力

defineTool

 + Schema 推导 + 自动注册

Agent Loop

核心驱动如何定制

实现 Agent 接口 + 注册到 AgentFactory

四种插件的适用场景对比:

场景

推荐插件类型

原因

接入内部模型网关

Service Provider

替换 ctx.llm 的实现,零 Consumer 改动

工具执行前加审批

Event Interceptortools/pre-execute

 是最自然的拦截点

让模型查询知识库

Tool PlugindefineTool

 自动处理 Schema 和类型安全

改成 Plan-and-Execute 循环

Agent Loop

替换 ReactLoopAgent,重写 turn/step 逻辑

需要同时替换模型和审批策略

组合

Provider + Interceptor 独立开发,无耦合

整体遵循的设计原则可以概括为十六个字:插件解耦,事件驱动,接缝替换,副作用可逆。

但底层逻辑不变——Harness 的责任不是替你写 Agent 逻辑,而是给模型一套安全、可控、可扩展的工程脚手架。插件化不是目的,目的是让你的创新不被框架的边界所限制。 理解了这一点,你就掌握了 Harness 插件开发的精髓。

Logo

一站式 AI 云服务平台

更多推荐