深色模式
第二步 · 迷你 LLM 接缝与两个适配器
目标:实现统一词汇表 + 适配器注册表 + 一个"假模型"与一个"真模型"。对应文件:
final-project/packages/mini-harness/src/llm/。对照原理篇 04与源码拆解 05。
2.1 词汇表(llm/types.ts)
从 dsh-llm 的词汇表中挑出教学必需的部分:Message(角色 + 内容块数组)、四种内容块、七种 StreamChunk、GenerateOptions、BlockAssembler。协议义务原样保留:
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 行内完成。