在上一章中,我们学习了Capability Seam 的三种角色设计——Service Definition、Service Provider 和 Consumer。LLM(大语言模型)是 Harness 中最核心的 Capability Seam 之一,也是三种角色设计最典型的应用场景。

在 Harness 中,Agent 的每一次“思考”都离不开大语言模型。但 Harness 本身并不绑定任何一个具体的模型提供方——DeepSeek、OpenAI、Anthropic、本地 Ollama,都可以接入。这一切都归功于 LLM 适配器(LLM Adapter) 。

LLM 适配器是 Harness 扩展模型生态的入口。它负责将 Harness 内部统一的请求格式(GenerateOptions)转换为具体提供方的 API 调用,再将提供方返回的流式响应转换为 Harness 统一的 StreamChunk 协议。

本章将带你理解 LLM 适配器的工作原理,并亲手实现一个最小适配器。你将学会:

  • LLM 适配器在 Harness 架构中的位置。

  • LlmAdapter 抽象类的核心方法 stream()。

  • StreamChunk 协议的完整规范。

  • 适配器的协议义务和错误处理。

  • 如何注册和配置一个自定义适配器。

LLM 适配器在Capability Seam中的位置

在 Harness 中,LLM 能力同样遵循三种角色设计:

角色

 包/组件

职责

Service Definition

@deepseek-ai/dsh-llm

定义 ctx.llm 服务接口、GenerateOptions 请求类型、StreamChunk 协议

Service Provider

LLM 适配器(如 llm-deepseek、llm-pi-ai)

实现具体的模型 API 调用,将协议请求转换为提供方请求,将提供方响应转换为 StreamChunk

Consumer

Agent Loop、工具等

通过 ctx.llm.stream() 调用模型,不关心具体是哪个提供方

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│   dsh-llm                        │──▶│  llm-deepseek                     │     │   Agent Loop                     │
│  (definition)                    │     │   (provider)                       │     │   (consumer)                     │
└─────────────────┘     └──────────────────┘     └─────────────────┘
       ▲                                                                                              │
       └───────────────────────────────────────────────┘
                                                  inject: ['llm']

Service Definition(dsh-llm)定义了 ctx.llm 这个服务,以及所有适配器必须遵守的 StreamChunk 协议。适配器(Provider)实现具体的 API 调用逻辑。Consumer(如 Agent Loop)通过 ctx.llm.stream() 发起请求,完全不知道也不关心背后是 DeepSeek 还是 OpenAI。

给初中级开发者的理解:可以把 LLM 适配器想象成“电源转换插头”。Harness 的请求格式是一种“插头标准”(GenerateOptions),每个模型提供方的 API 是另一种“插座标准”。适配器的工作就是把 Harness 的插头转换成目标提供方能接受的形状,同时把提供方返回的电流(流式响应)转换成 Harness 能识别的格式(StreamChunk)。

适配器的最小结构

一个 LLM 适配器的最小结构非常简单:

import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'

class MyAdapter extends LlmAdapter {
    async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
        // 在这里实现具体的模型调用逻辑
    }
}

export const name = 'llm-myprovider'
export const inject = ['llm']

export function apply(ctx: Context) {
    ctx.llm.registerAdapter(['my-provider'], new MyAdapter(/* ... */))
}

逐行来看:

部分

说明

extends LlmAdapter

继承 LlmAdapter 抽象类,这是定义适配器的“入场券”

async * stream()

异步生成器函数,核心方法——接收请求选项,产出 StreamChunk 流

export const inject = ['llm']

声明依赖 llm 服务,因为我们要调用 ctx.llm.registerAdapter()

ctx.llm.registerAdapter()

将适配器注册到 llm 服务上,绑定一个或多个提供方路由

重要提示:registerAdapter 基于 Effect 模式实现注册,天然兼容 HMR。需注意:每个 provider route 至多绑定一个适配器,重复注册会触发异常;多路由注册具备原子性保证,即全部成功或全部失败,不存在部分成功状态。

GenerateOptions:适配器收到的请求

当 Agent 需要调用模型时,它会通过 ctx.llm.stream() 发起请求,并传入一个 GenerateOptions 对象。适配器的 stream() 方法接收的就是这个对象。

GenerateOptions 包含了模型调用所需的所有信息:

字段

类型

说明

provider

string

提供方路由名,用于选择适配器

model

string

提供方模型 ID

messages

Message[]

对话历史

tools

ToolSchema[]

可调用的工具列表

temperature

number

温度参数

maxTokens

number

最大生成 token 数

stop

string[]

停止序列

signal

AbortSignal

取消信号

purpose

string

请求目的(如 'session-title')

适配器的职责就是:将 GenerateOptions 中的信息转换为目标提供方 API 所需的请求格式。

给初中级开发者的提示:GenerateOptions 是 Harness 内部的“通用语言”。无论适配器对接的是 DeepSeek、OpenAI 还是 Ollama,它们收到的都是同一套数据结构。适配器需要做的是“翻译”——把通用语言翻译成提供方 API 能听懂的语言。

StreamChunk 协议:适配器必须产出的“通用语言”

stream() 方法返回的不是一个简单的字符串,而是一个 StreamChunk 流——一系列类型化的数据块(chunk),交错发送。

StreamChunk 是 Harness 内部统一的流式响应协议。无论底层是哪个模型提供方,适配器都必须将提供方的响应转换成 StreamChunk 流。

一个流式响应可能交错包含多种类型的块:

  • 文本块(text):模型生成的普通文本。

  • 推理块(reasoning):模型的“思考”过程(如 DeepSeek 的 thinking mode)。

  • 工具调用块(tool-call):模型决定调用工具。

  • 工具结果块(tool-result):工具执行的结果。

StreamChunk 的类型定义大致如下:

type StreamChunk =
    | { type: 'block-start'; index: number; block: ... }
    | { type: 'text-delta'; index: number; text: string }
    | { type: 'reasoning-delta'; index: number; text: string }
    | { type: 'tool-call-delta'; index: number; id: string; name: string; argumentsDelta: string }
    | { type: 'block-end'; index: number; block: ... }
    | { type: 'usage'; promptTokens: number; completionTokens: number; totalTokens: number }
    | { type: 'finish'; kind: 'stop' | 'length' | 'error' | 'aborted'; ... }

理解 index 的作用

index 将每个 delta(增量片段)关联到它所属的块(block)。如果模型在一次响应中同时生成文本和调用两个工具,那么:

  • 文本块的 index 可能是 0。

  • 第一个工具调用块的 index 可能是 1。

  • 第二个工具调用块的 index 可能是 2。

每个块的 block-start 和 block-end 共享同一个 index,中间的 delta 也使用这个 index。

给初中级开发者的理解:可以把 StreamChunk 想象成“乐高积木的快递包裹”。每个块(block)是一堆积木,index 是包裹编号。block-start 告诉你“一个新的包裹到了”,text-delta 是包裹里的一块积木,block-end 告诉你“这个包裹的所有积木都到齐了”。多个包裹可以同时在路上(交错发送),通过 index 来区分它们属于哪个包裹。

协议义务:适配器必须遵守的规则

Harness 的官方文档明确列出了适配器必须遵守的协议义务。这些规则是两个官方参考实现(llm-deepseek 和 llm-pi-ai)都经过验证的约定。

1. usage 必须在 finish 之前发出

在 finish 之前发出 usage;finish 之后不再发出任何内容。

稳健的做法是:缓冲 finish/usage,直到提供方的流结束标记出现,再统一 flush。这样可以处理某些提供方在流末尾发送仅含 usage 的分片的情况。

// 错误:先发 finish 再发 usage
yield { type: 'finish', kind: 'stop' }
yield { type: 'usage', totalTokens: 100 }  // 太晚了!

// 正确:先缓存,再按正确顺序发出
let pendingUsage = null
let pendingFinish = null
// ... 解析流,收集 usage 和 finish
yield pendingUsage   // 先发 usage
yield pendingFinish  // 再发 finish
2. 工具调用的 arguments 是原始 JSON 字符串

工具调用的 arguments 全程为原始 JSON 字符串;流式片段以 argumentsDelta 发送。

如果你的提供方返回的是已解析的对象(而不是字符串),请在 block-end 时将其重新 stringify。

// 如果提供方返回了 parsed object
const parsedArgs = { city: "Beijing" }
// 必须重新 stringify
const argsString = JSON.stringify(parsedArgs)
yield { type: 'tool-call-delta', index: 0, argumentsDelta: argsString }
3. 按首次出现的流顺序分配 index

按首次出现的流顺序分配块 index;同一个块的每次 delta 复用该 index。

4. 错误有且仅有两条合法路径
  1. 从 stream() 抛出:用于传输与协议故障——使用带稳定 code 的 LlmError。

  2. 以 finish {kind: 'error' | 'aborted'} 结束流:用于表示提供方带内故障。

// 路径一:抛出异常(网络故障、协议错误)
throw new LlmError('NETWORK_TIMEOUT', 'Connection timed out')

// 路径二:用 finish 结束流(提供方返回了错误)
yield { type: 'finish', kind: 'error', error: 'Model overloaded' }

消费方两者都能处理;请按故障类别选择路径并加以文档化。

5. 遵守 options.signal

遵守 options.signal——将其传递给 fetch 或你的 SDK。

async * stream(options: GenerateOptions) {
    const response = await fetch(url, {
        signal: options.signal,  // 必须传递!
        // ...
    })
}
6. 不支持的字段要明确报错

如果 GenerateOptions 中某个字段你的提供方无法支持(例如提供方不支持 stop 序列时收到了 stop 列表),抛出 LlmError(..., 'UNSUPPORTED'),而非静默丢弃。

7. replayState:适配器私有回放数据

如果提供方在后续调用中需要响应 ID、签名或其他原生元数据,请将其最小无损 JSON 投影作为 finish.replayState 发出。

LlmRuntime 只有在历史提供方路由与目标提供方路由当前属于完全相同的适配器实例时,才会传递该状态。

yield {
    type: 'finish',
    kind: 'stop',
    replayState: {
        responseId: 'resp_abc123',
        // 提供方后续调用需要的任何元数据
    }
}

状态缺失时,切勿仅根据提供方/模型名称推断原生回放。

给初中级开发者的提示:replayState 是一个高级特性,主要用于支持对话历史恢复的场景。对于大多数适配器实现,你可能暂时用不到它。但了解它的存在,能帮你理解 Harness 如何支持复杂的多轮对话场景。

完整示例:实现一个 Echo 适配器

下面,我们将从零开始,构建一个完整、可运行的 Echo 适配器插件。你将在自己的 Harness 环境中看到这个适配器实际工作,并理解适配器开发的完整流程。

目标

我们将创建一个名为 llm-echo 的插件,它注册一个名为 echo 的 LLM 提供方(provider)。这个适配器不会调用任何真实的模型 API,而是将用户输入的文本原样返回,并加上配置的前缀。

通过这个示例,你将学会:

  1. 如何搭建适配器插件的目录结构。

  2. 如何正确实现 LlmAdapter 的 stream 方法。

  3. 如何注册适配器并指定 provider route。

  4. 如何在 Web UI 中切换并使用自定义适配器。

  5. 如何验证适配器的输出。

第一步:创建目录结构

我们将在 DeepSeek Harness 仓库根目录下创建一个独立的插件目录,这样便于管理和分发。

cd /path/to/deepseek-harness
mkdir -p echo-adapter/src

目录结构如下:

echo-adapter/
├── src/
│   └── index.ts         # 插件主文件
└── cordis.yml            # 补丁配置文件(加载插件)
└── tsconfig.json         # 类型检查,不参与仓库的正式编译产出流程

如果你希望将这个适配器打包成可分享的组合包,还可以添加 package.json。但为了本教程的简洁性,我们采用本地 --patch 加载方式。

// tsconfig.json

{
  "extends": "../tsconfig.base.json",
  "compilerOptions": {
    "composite": false,
    "noEmit": true
  },
  "include": ["src/**/*.ts", "*.ts"]
}

第二步:编写适配器代码

在 echo-adapter/src/index.ts 中编写完整的适配器实现:

import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { LlmAdapter, type GenerateOptions, type LlmModelInfo, type StreamChunk, type TextBlock } from '@deepseek-ai/dsh-llm'

// -------- 插件配置 --------
export interface Config {
    prefix: string
}

export const Config = Schema.object({
    prefix: Schema.string().default('Echo: '),
})

// -------- 适配器类 --------
class EchoAdapter extends LlmAdapter {
    private readonly prefix: string

    constructor(prefix: string) {
        super()
        this.prefix = prefix
    }

    // 必须实现 listModels
    // 默认返回空数组,空分组被 filter(group => group.models.length > 0) 过滤掉,UI 不显示
    // 适配器注册成功(listProviders 能看到),但 UI 模型选择器中不显示,因为它的模型列表为空被过滤了。
    override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
        return Promise.resolve([
            { provider, id: 'echo-default', name: 'Echo Default' },
        ])
    }

    async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
        // 1. 提取最后一条用户消息中的文本
        const lastUserMessage = options.messages
            .filter(m => m.role === 'user')
            .pop()

        const inputText = lastUserMessage?.content
            .filter(block => block.type === 'text')
            .map(block => (block as TextBlock).text)
            .join(' ') || ''

        // 2. 构造响应文本
        const response = `${this.prefix}${inputText}`

        // 3. 发送流式块(模拟逐字输出)
        yield {
            type: 'block-start',
            index: 0,
            blockType: 'text',
        }

        for (const char of response) {
            yield {
                type: 'text-delta',
                index: 0,
                text: char,
            }
            // 模拟网络延迟,让流式效果更明显
            await new Promise(resolve => setTimeout(resolve, 10))
        }

        yield {
            type: 'block-end',
            index: 0,
            block: { type: 'text', text: response },
        }

        // 4. 发送用量统计(必须在 finish 之前)
        yield {
            type: 'usage',
            usage: {
                inputTokens: inputText.length,
                outputTokens: response.length,
            },
        }

        // 5. 发送结束标记
        yield {
            type: 'finish',
            reason: { kind: 'stop' },
        }
    }
}

// -------- 插件导出 --------
export const name = 'llm-echo'
export const inject = ['llm']

export function apply(ctx: Context, config: Config) {
    // 注册适配器,provider route 为 'echo'
    ctx.llm.registerAdapter(['echo'], new EchoAdapter(config.prefix))
    console.log(`[llm-echo] adapter registered with prefix: "${config.prefix}"`)
}
第三步:创建补丁配置文件

在 echo-adapter/cordis.yml 中写入:

- insert:
    - id: llm-echo
      name: 'file:///E:/ProjectsAI/deepseek-harness/echo-adapter/src/index.ts'
      config:
        prefix: 'You said: '

这里我们为适配器配置了 prefix: 'You said: ',这样用户输入 "Hello" 会得到 "You said: Hello"。

第四步:启动 Harness 并加载适配器

在仓库根目录执行以下命令启动 Web UI:

pnpm dsh web --patch ./echo-adapter/cordis.yml

观察控制台日志,你应该看到:

[llm-echo] adapter registered with prefix: "You said: "

这证明适配器已成功加载并注册。

第五步:在 Web UI 中使用适配器
  1. 打开浏览器访问 http://127.0.0.1:3080。

  1. 在对话界面中,你会看到一个模型选择下拉框,接着点击模型选择框,展开列表,再选择 “Echo Default”。

  2. 在输入框中输入任意文本,例如 "Hello, Harness!",然后发送。

你会看到模型以流式方式逐字输出:

You said: Hello, Harness!

恭喜!你的自定义 LLM 适配器已经成功运行。

总结

LLM 适配器是 Harness 中最高级的扩展点之一。它涉及到流式协议、错误处理、配置管理等多个方面的知识。但理解了适配器,你就理解了 Harness 如何做到“模型无关”——Agent 的思考引擎可以随时更换,而整个系统不受影响。

 

你已经完成了从基础篇到实战篇的全部学习内容。现在,你可以:

  • 编写自己的 Harness 插件。

  • 为插件添加工具和配置。

  • 打包和分享你的插件。

  • 理解插件的生命周期和服务机制。

  • 使用事件实现插件间通信。

  • 设计可替换的能力体系。

  • 接入自定义的 LLM 提供方。

 

Harness 的世界远不止这些——你还可以探索 UI 插件、会话管理、沙箱策略等更多领域。但你已经拥有了最核心的知识基础,足以支撑你在 Harness 生态中自由探索。