实战进阶

LLM 适配器

编写自定义模型适配器插件,支持 DeepSeek-V3、R1 思维链拆分、本地私有化 Ollama/vLLM 接入与流式解析。

在企业级 AI 智能体落地的实际场景中,大语言模型往往部署在多样的异构基础设施上——从 DeepSeek 官方公有云 API、企业私有云部署的 vLLM / Ollama 集群,到具有特定安全鉴权网关的代理服务。

DeepSeek Harness 通过 @deepseek-ai/dsh-llm 提供了高度解耦的 LlmAdapter 基类6 阶段标准流式协议(StreamChunk Protocol)。无论底层推理引擎是何种接口格式,只需实现一个适配器插件,即可无缝融入 Harness 的智能体规划循环中。

本文将带领你从零编写一个 “企业级私有化 vLLM / Ollama 推理适配器(Self-Hosted vLLM Adapter)”


架构透视:6 阶段 StreamChunk 标准流式协议

Harness 的调度器与 Trajectory 日志追踪器依赖统一的事件切片协议与模型进行流式交互:

┌─────────────────────────────────────────────────────────────┐
│                 6 阶段 StreamChunk 标准流式协议             │
├─────────────────────┬───────────────────────────────────────┤
│ 1. block-start      │ 开启一个文本内容块或工具调用块        │
├─────────────────────┼───────────────────────────────────────┤
│ 2. text-delta       │ 流式推送思考过程与文本增量 Token      │
├─────────────────────┼───────────────────────────────────────┤
│ 3. tool-call-delta  │ 流式拼接 Tool Call 参数的 JSON 片段   │
├─────────────────────┼───────────────────────────────────────┤
│ 4. block-end        │ 声明当前内容块或工具调用块结束        │
├─────────────────────┼───────────────────────────────────────┤
│ 5. usage            │ 上报 Token 统计及 DeepSeek 命中缓存量 │
├─────────────────────┼───────────────────────────────────────┤
│ 6. finish           │ 声明本次推理流完成,携带 finishReason │
└─────────────────────┴───────────────────────────────────────┘

步骤一:继承 LlmAdapter 实现适配器

创建 custom-plugins/vllm-adapter/src/vllm-adapter.ts 文件:

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

export interface VllmConfig {
    endpoint: string;
    modelName: string;
    apiKey?: string;
}

export class VllmCustomAdapter extends LlmAdapter {
    constructor(private config: VllmConfig) {
        super();
    }

    // 1. 生成带有 DeepSeek 优化标识的 HTTP 请求头
    public override attributionHeaders(): Record<string, string> {
        return {
            'X-Client-Agent': 'DeepSeek-Harness-vLLM-Adapter/1.0',
            'X-Enable-KV-Cache-Reuse': 'true'
        };
    }

    // 2. 核心异步生成器:将底层 SSE 数据流转换为 StreamChunk 标准格式
    public override async *generate(options: GenerateOptions): AsyncGenerator<StreamChunk> {
        const url = `${this.config.endpoint}/v1/chat/completions`;

        let response: Response;
        try {
            response = await fetch(url, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'Authorization': `Bearer ${this.config.apiKey ?? 'none'}`,
                    ...this.attributionHeaders()
                },
                body: JSON.stringify({
                    model: this.config.modelName,
                    messages: options.messages,
                    stream: true,
                    temperature: options.temperature ?? 0.0
                })
            });
        } catch (err: any) {
            throw new LlmError(LlmErrorCode.NETWORK_ERROR, `无法连接到私有模型网关: ${err.message}`);
        }

        if (!response.ok) {
            const errorText = await response.text();
            throw new LlmError(
                response.status === 401 ? LlmErrorCode.AUTHENTICATION_FAILED : LlmErrorCode.INFERENCE_FAILED,
                `私有模型网关返回错误 (HTTP ${response.status}): ${errorText}`
            );
        }

        // 3. 模拟 SSE 解析与 StreamChunk 流式转换
        yield { type: 'block-start', blockType: 'text', index: 0 };
        yield { type: 'text-delta', delta: '正在通过私有化 vLLM 集群处理您的请求...', index: 0 };
        yield { type: 'block-end', index: 0 };

        // 4. 上报 Token 消耗与 DeepSeek 专属 KV Cache 命中统计
        yield {
            type: 'usage',
            usage: {
                promptTokens: 120,
                completionTokens: 25,
                totalTokens: 145,
                cacheReadTokens: 90 // 命中 KV Cache 的前缀 Token
            }
        };

        // 5. 声明流完成
        yield { type: 'finish', finishReason: 'stop' };
    }
}

步骤二:将适配器封装为 Cordis 插件

创建 custom-plugins/vllm-adapter/src/index.ts,在微内核中注册适配器:

typescript
import type { Context } from '@deepseek-ai/cordis';
import { VllmCustomAdapter, type VllmConfig } from './vllm-adapter';

export const name = 'vllm-adapter-plugin';
export const inject = ['llm'];

export function apply(ctx: Context, config: VllmConfig) {
    const adapter = new VllmCustomAdapter(config);

    // 将适配器注册到全局 llm 服务的提供商列表
    ctx.llm.registerAdapter('vllm-private', adapter);
    console.log(`[vLLM]  私有模型适配器已成功挂载 (模型: ${config.modelName})`);
}

步骤三:在 cordis.yml 中声明使用私有适配器

yaml
mode: standard

model:
  # 指定使用刚刚注册的 vllm-private 适配器
  provider: vllm-private
  name: deepseek-ai/DeepSeek-V3
  endpoint: http://192.168.1.100:8000
  apiKey: env(PRIVATE_CLUSTER_KEY)

plugins:
  - name: "./custom-plugins/vllm-adapter"
    config:
      endpoint: "http://192.168.1.100:8000"
      modelName: "deepseek-ai/DeepSeek-V3"
  - name: "@deepseek-ai/dsh-plugin-bash"

常见问题解答 (FAQ)

Q1: 为什么必须按照 6 阶段协议推送 StreamChunk?

解答:Harness 的上层 UI 前端(如 Web 控制台中的思考流折叠栏)以及 Trajectory 审计追踪器,依赖 block-startblock-end 边界来安全地隔离文本生成与 JSON 工具调用。若缺少生命周期分片,前端将无法正确渲染工具执行进度。

Q2: 遇到 API 限流或鉴权失败时,为什么要抛出 LlmError 而不是常规的 Error

解答:Harness 调度器内置了针对 LlmError 的智能重试与自动降级策略。例如遇到 RATE_LIMIT_EXCEEDED 时会自动执行指数退避重试,而常规的 Error 会被视为不可恢复的致命异常直接终止会话。

Q3: cacheReadTokens 字段有什么用?

解答:这是针对 DeepSeek 模型架构的重要优化指标。DeepSeek 对命中 KV Cache 的前缀输入仅收取常规费用的 10%~20%。精准记录该字段能够让企业清晰评估 Agent 调用的成本收益。

Q4: 如何在适配器中支持大模型流式思考过程(Reasoning / Thought)?

解答:在解析 SSE 流时,如果检测到 DeepSeek-R1 的思考标签,可 yield { type: 'block-start', blockType: 'thinking', index: 0 },并通过 text-delta 推送思考内容,最后通过 block-end 闭合。