ByteNoteByteNote
Cordis 教程 07:注册工具,观察结果事件
字

字节笔记本

2026年10月6日 · 约 9 分钟读完

Cordis 教程 07:注册工具,观察结果事件

API中转
¥120

本文整理自 DeepSeek 开源 agent 框架 deepseek-harness 的官方 Cordis 入门教程,是该系列的实践收尾。这一系列从第一个插件、生命周期与 effect、服务注入一路讲到事件与组合,本章把它们拧成一次真实运行:向 harness 的 tools 服务注册一个模型可调用的工具,经真实工具流水线执行一遍,再用一个独立插件观察结果事件。整个过程不需要任何 API 密钥,也不会调用模型,模型的位置由代码自己顶上。

Cordis 工具流水线全景:一份组合文件、内核服务与六步执行链

工具插件:把 greet 注册进 tools 服务

创建 greet-tool.ts,放在教程目录的 tmp/cordis-tutorial 下:

ts
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { CallId } from '@deepseek-ai/dsh-llm'

export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'greet',
    description: 'Greet the named person.',
    parameters: {
      name: { type: 'string', required: true, description: 'Who to greet' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args) {
      return `Hello, ${args.name}!`
    },
  }))

  // Drive one call through the real execution pipeline, standing in for
  // the model. CallId brands the correlation id a provider would issue.
  void (async () => {
    const result = await ctx.tools.execute({
      callId: CallId('demo-1'),
      name: 'greet',
      arguments: { name: 'Cordis' },
      signal: new AbortController().signal,
    })
    console.log('tool replied:', JSON.stringify(result.content))
  })()
}

代码不长,信息量却不小:

  • inject: ['tools'] 是依赖声明,插件会一直等待,直到工具注册表服务就绪才启动,避免拿到一个空注册表;
  • ctx.tools.register(...) 把工具注册进注册表,注册动作产生的注销函数会挂到插件自己的生命周期上,插件卸载时工具随之注销,不留悬空能力;
  • defineTool 一肩挑三件事:把 parameters 规约转成给模型看的 JSON Schema,推导 execute 入参的 TypeScript 类型,并在 execute 执行前校验模型传来的参数;
  • output.schema 声明工具的规范返回值,output.render 则另行生成可持久化的结果内容,规范值与呈现内容从此分工。

插件后半段用 ctx.tools.execute(...) 手动发起一次调用,站在模型的位置走完整流水线:CallId 给调用打上关联 ID,模拟真实 provider 下发的编号;signal 传入中止信号,供上层随时叫停。也就是说,除了调用方从模型换成了这段代码,其余环节全是真的。

观察插件:监听 tools/result 事件

再创建 tool-logger.ts。它与 greet-tool 没有任何依赖关系,是一个纯观察者:

ts
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-tools'

export const name = 'tool-logger'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.on('tools/result', (exec, result) => {
    const text = result.content
      .map(block => (block.type === 'text' ? block.text : ''))
      .join('')
    console.log(`[tool-logger] ${exec.name} -> ${text}`)
  })
}

import type {} from '@deepseek-ai/dsh-tools' 这行看似空操作,实际是把该包的声明合并引入当前工程,让 'tools/result' 事件名及其载荷获得完整类型。事件回调从结果内容块中拼出文本,把每次工具调用的名字与输出打印出来。应用里的每一次工具调用都逃不过它的眼睛。

组合并运行

把四个插件写进组合文件 cordis.yml:

yaml
- name: '@deepseek-ai/dsh-system-prompt'
- name: '@deepseek-ai/dsh-tools'
- name: './tool-logger.ts'
- name: './greet-tool.ts'

注意 @deepseek-ai/dsh-system-prompt 必须在清单里。工具要向系统提示词贡献自己的 schema,所以 dsh-tools 会声明注入 systemPrompt 服务,组合文件就得同时列出该服务的提供方;少了这一行,tools 插件会因为依赖无法满足而停留在 PENDING 状态,什么也不执行。

启动内核:

sh
node --import tsx ../../vendor/cordis/bin.js

输出两行:

text
[tool-logger] greet -> Hello, Cordis!
tool replied: [{"type":"text","text":"Hello, Cordis!"}]

终端实录:logger 先触发,execute 的 promise 后兑现

两行的先后顺序有讲究:logger 先触发,因为 tools/result 是在结果物化过程中发出的,早于 execute 的 promise 向调用方兑现。更值得注意的是,greet-tool 与 tool-logger 互相不知道对方存在,没有任何 import 关系,把它们连起来的是两件东西:注册表服务和事件。能力之间零耦合,靠服务与事件这两个枢纽协作,这正是插件式框架的核心打法。

离完整 agent 还差几块

这份组合再补几个插件就是一个真正的 agent:LLM 适配器负责接模型,agent loop 负责循环推进,持久化负责保存会话,再加一个运行入口。框架仓库 examples 里的 headless-agent 示例组合文件,现在每一行都能读懂了:把自己的 greet-tool 加进那份文件的副本,工具就会出现在模型可调用的清单里,剩下的循环交给框架。

想继续深入,可以顺着几条线走:defineTool 的完整用法,包括呈现层与更复杂的参数 schema;框架的三层能力设计,看可替换能力如何组织;子系统页面上的 cordis-surface 区块,列出所有可注入的服务与可监听的事件;以及整体架构文档,看这些插件在系统地图里的位置。工具写好、流水线跑通之后,距离让模型真正调用它,只差一层组合文件的配置。

相关文章

分享: