Skip to content

第二步 · 迷你 LLM 接缝与两个适配器

目标:实现统一词汇表 + 适配器注册表 + 一个"假模型"与一个"真模型"。对应文件:final-project/packages/mini-harness/src/llm/。对照原理篇 04源码拆解 05

2.1 词汇表(llm/types.ts

从 dsh-llm 的词汇表中挑出教学必需的部分:Message(角色 + 内容块数组)、四种内容块、七种 StreamChunkGenerateOptionsBlockAssembler。协议义务原样保留:

ts
export type StreamChunk =
  | { type: 'block-start'; index: number; blockType: ContentBlock['type'] }
  | { 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: ContentBlock }
  | { type: 'usage'; usage: TokenUsage }
  | { type: 'finish'; reason: FinishReason }

为什么 tool-call 的 arguments 是字符串:模型产出的原始 JSON 保持字节不变,中途不解析不重序列化(原理篇 4.2)。mini-harness 的执行侧(agent.ts)在真正执行前才 JSON.parse,解析失败会变成结构化的工具错误返回模型——这个分层在真实 dsh 里完全一致。

BlockAssembler 也照搬了容错语义:block-end 之后到达的增量被忽略,delta-only 的流也能组装。

2.2 接缝(llm/adapter.ts

ts
export abstract class LlmAdapter {
  abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>
}

export class LlmRuntime extends Service {
  registerAdapter(providers: string[], adapter: LlmAdapter): () => void { /* 一个路由一个适配器 */ }
  stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
    const dispatch = () => this.adapterStream(options)
    return this.ctx.waterfall('llm/stream', options, dispatch)   // 每次调用经过瀑布
  }
}

adapterStream 是"最终适配器边界"(final adapter boundary):

ts
private async *adapterStream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  const adapter = this.adapters.get(options.provider)
  if (!adapter) {
    yield { type: 'finish', reason: { kind: 'error', failure: { code: 'NO_ADAPTER', … } } }
    return
  }
  try {
    for await (const chunk of adapter.stream(options)) yield chunk
  } catch (err) {
    // 取消 → aborted;其他 → error。消费方永远拿到合法终止,而不是裸异常
    yield { type: 'finish', reason: options.signal?.aborted
      ? { kind: 'aborted', … } : { kind: 'error', … } }
  }
}

2.3 Mock 适配器(llm/adapters/mock.ts

Demo 5 的插件同一套逻辑,但这次运行在你自己的接缝上。它的"智能"只有三行:

ts
const shouldCallEcho = Boolean(echo) && !toolResult && task.includes('echo')
// ① 请求带 echo 工具 ② 消息里还没有工具结果 ③ 任务文本含 "echo"
// → 第一步发工具调用;否则有工具结果就引用结果作答,再否则回显任务

它演示了适配器侧的真相:agent-loop 第二次调用时,消息里真的带着 tool-result 块。写它的时候你就理解了"工具循环"的模型侧契约。

2.4 OpenAI 兼容适配器(llm/adapters/openai-compat.ts

约 170 行,用零依赖的 fetch + SSE 手写解析,对接任意 OpenAI 兼容端点。翻译分两步:

出站(统一词汇表 → wire):

ts
messages: options.messages.map((m) => ({
  role: m.role,
  content: m.content.map((b) => {
    if (b.type === 'tool-call') return { type: 'tool_call', id: b.id, function: { … } }
    if (b.type === 'tool-result') return { type: 'tool_result', tool_call_id: b.toolCallId, … }
    return { type: b.type }
  }),
})),

入站(SSE → StreamChunk):content 增量 → text-delta;reasoning_content → reasoning-delta;tool_calls 增量 → tool-call-delta(参数字符串直接拼接,不做任何解析——遵守词汇表约定);finish_reason 到达时先发完所有 block-end、再发 usage、最后发 finish

本教程亲手踩过的坑:忘了 block-end

初版 openai-compat 只发了 delta,没在结束时发 block-end。结果 BlockAssembler.message() 的内容为空——模型"说了一堆话"但组装结果是空的。这正是协议义务存在的意义:协议规定"块必须以 block-end 收尾",因为组装器只认 block-end 冻结块。修复代码里的 emitBlockEnds() 就是那次踩坑的产物。

2.5 测一测

sh
cd final-project
npm run demo        # mock 适配器全链路
# 真实模型(可选):
OPENAI_API_KEY=sk-xxx npm run dev:server   # 前端把提供方切到 openai-compat

小练习:写一个"复读机"适配器

实现 RepeatAdapter:把 options.messages 的最后一条用户消息原样作为 assistant 回复(记得遵守协议义务:block-start → delta → block-end → usage → finish)。注册为 provider repeat,在前端选择它试试。10 行内完成。

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写