这是《DeepSeek Harness 权威指南 》系列的第 6 篇。 本文源码基线为 deepseek-harness @ 47f9438(v0.1.0-rc.5)。源码和中文文档结论在首次出现处说明来源;本机 mock demo 只说明该脚本、命令和环境下的行为。

dsh 用适配器把模型提供方的 API 格式、协议和鉴权差异隔离在插件层。 核心代码面对的是统一消息和 StreamChunk 词汇表,而不是某家 SDK。本文的本机 demo 注册了 mock-alphamock-beta 两个 mock provider,证明同一消费与组装逻辑可处理两条适配器流;它构成 DeepSeek 与 PI.AI 的真实互换实测,也不能承诺任意提供方只改一个字段就可运行。

系列第 5 篇讲了工具系统(模型的手和眼睛),本篇往上游走一层:模型请求怎么发出、流式输出怎么回来、失败怎么重试、token 怎么计量。 核心问题只有一个:确定性代码怎么和一个"每次输出都不同"的概率系统安全打交道。答案是三个对策——把差异关进适配器、把过程当流、把失败当常态。

一、先搞懂:模型 API 为什么需要"翻译层"

假设你要让 dsh 用上某个新模型服务。这个服务大概率是这三种之一:

  • OpenAI 兼容端点/chat/completions,messages 数组 + stream
  • Anthropic 风格端点/v1/messages,system + messages 分离
  • 私有协议:自己的 SDK、自己的鉴权、自己的流式格式

它们的共同点是:请求和响应格式都不一样,但语义一样——都是"给一段对话历史,返回一段生成文本(可能带工具调用)"。

dsh 的做法是定义一个提供方无关的词汇表:统一的消息格式(Message,系列第 4 篇讲过)、统一的流式格式(StreamChunk,本篇重点)。每个提供方由一个适配器把这套词汇表映射为它的 API 调用,再把响应映射回来。

适配器隔离的是“上线路由协议”,不是把所有接入成本变成零。 会话、工具和驱动器可以只消费统一词汇表;但凭据、端点、模型目录、请求字段映射,以及某些功能是否受支持,仍由适配器和 profile 配置承担。对一个已完成这些配置的部署,GenerateOptions.provider 会选择已注册的适配器,model id 则传给该适配器;因此“切换路由不改核心消费代码”是 seam 的实际收益。能否只改一个配置字段,还取决于目标路由是否已注册、鉴权是否可用,以及它能否兑现当前请求字段。

二、适配器 seam:一个抽象类,一个必选方法

适配器的唯一必选方法是 stream()。实际源代码只有下面这段抽象契约(packages/llm/llm/src/index.ts:227-232):

  /**
   * Stream one model call as raw chunks. The only required method.
   * @param options - the fully-assembled request; implementations must honor `options.signal`.
   * @returns the chunk stream, obeying the adapter contract documented on `StreamChunk`.
   */
  abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>

中文意思是:流式执行一次模型调用;这是唯一必选方法。输入是已经完整组装的请求,适配器必须响应 options.signal;输出必须遵守 StreamChunk 的流协议。其他方法如模型目录和 provider 元数据是可选能力,不能把它们误写成适配器必需实现。

注册不是一个“把对象塞进全局表”的黑盒。LlmRuntime.registerAdapter() 的实际实现如下(packages/llm/llm/src/index.ts:330-367):

  /**
   * Register an adapter for the given provider routes. Throws `LlmError` with code
   * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).
   * Disposed with the fiber.
   * @param providers - every provider route this adapter should serve.
   * @param adapter - the adapter that streams calls for those providers.
   * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.
   */
  registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle {
    // The routes this registration currently holds; `replace` rewrites it, and
    // the disposer releases whatever it holds at disposal time.
    const owned = new Set<string>()
    // The disposer has run: `owned` being empty cannot say so on its own,
    // because `replace([])` legally leaves a live registration holding none.
    let released = false
    const dispose = this.ctx.effect(function* (this: LlmRuntime) {
      if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER')
      this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned))
      yield () => {
        released = true
        for (const provider of owned) this.adapters.delete(provider)
        owned.clear()
        this.emitAdaptersUpdated()
      }
    }.bind(this), 'llm.registerAdapter()')
    // ctx.effect's disposer returns Promise<void>; our disposer API is
    // synchronous fire-and-forget — discard the (always-resolved) promise.
    const handle = (() => void dispose()) as AdapterRegistrationHandle
    handle.replace = (next: string[]): void => {
      // Registering here would leak: the effect's disposer already ran, so
      // nothing remains to release whatever this call would put in the map.
      if (released) {
        throw new LlmError('a disposed adapter registration cannot replace its routes', 'REGISTRATION_DISPOSED')
      }
      this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned))
    }
    return handle
  }

英文注释的意思是:同一个注册能拥有多个 provider 路由;任一路由已被其他 adapter 占用,整组注册以 DUPLICATE_ADAPTER 失败,不会只成功一半。它由当前 Cordis fiber 持有,fiber 卸载时会删除仍归它所有的路由。返回的函数用于释放注册,replace() 在注册仍存活时更新该实例的路由;释放之后再替换会以 REGISTRATION_DISPOSED 失败。这里的“替换”是路由注册行为,不等于已经验证新 provider 的鉴权、模型或请求映射可用。

dsh LLM 适配器 seam:统一请求在适配器层映射为提供方调用

图:在流式调用路径上,LlmRuntime 依赖 stream()StreamChunk;provider metadata、配置与能力差异仍由各适配器和部署分别处理。本文本机示例只注册两个 mock adapter,不代表对 DeepSeek、PI.AI 或其他实际端点的互换实测。

下面是作者本机 mock 示例:两个 MockAdapter 各自实现 stream()、注册到两个 provider,并由同一份消费逻辑读取。该 scratch 脚本不属于 47f9438 的已提交源码,不能被表述成真实 DeepSeek/PI.AI provider 测试。

/**
 * A5 LLM 与流式实证:适配器 stream 契约 + BlockAssembler 组装
 * 作者本机运行(cwd=E:/coding/deepseek-harness):
 * node --import tsx E:/coding/rex-hugo/.tmp-research/dsh-llm-demo/adapter-stream-demo.ts
 */
import { Context } from '@deepseek-ai/cordis'
import {
  BlockAssembler, CallId, LlmAdapter, LlmRuntime,
  type GenerateOptions, type StreamChunk,
} from '@deepseek-ai/dsh-llm'

/** 最小适配器:继承 LlmAdapter,唯一必选方法是 stream() */
class MockAdapter extends LlmAdapter {
  constructor(private flavor: string) {
    super()
  }

  providerInfo(provider: string) {
    return { id: provider, name: `${provider} (${this.flavor})` }
  }

  async *stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
    // 1. 文本块:block-start -> text-delta* -> block-end
    yield { type: 'block-start', index: 0, blockType: 'text' }
    yield { type: 'text-delta', index: 0, text: `你好,我是 ${this.flavor} 模型。` }
    yield { type: 'text-delta', index: 0, text: ' 我会流式输出。' }
    yield {
      type: 'block-end', index: 0,
      block: { type: 'text', text: `你好,我是 ${this.flavor} 模型。 我会流式输出。` },
    }
    // 2. 工具调用块:argumentsDelta 可以分多个 chunk 增量到达
    yield { type: 'block-start', index: 1, blockType: 'tool-call' }
    yield { type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'read_file', argumentsDelta: '{"path":"/tmp/a.txt' }
    yield { type: 'tool-call-delta', index: 1, id: CallId('c1'), name: 'read_file', argumentsDelta: '"}' }
    yield {
      type: 'block-end', index: 1,
      block: { type: 'tool-call', id: CallId('c1'), name: 'read_file', arguments: '{"path":"/tmp/a.txt"}' },
    }
    // 3. 用量与结束原因:usage 必须在 finish 之前
    yield { type: 'usage', usage: { inputTokens: 12, outputTokens: 30 } }
    yield { type: 'finish', reason: { kind: 'tool-calls' } }
  }
}

async function main() {
  const app = new Context()
  await app.plugin(LlmRuntime)

  // 注册两个 provider,各自挂一个适配器 —— 换 provider 就是换适配器
  app.llm.registerAdapter(['mock-alpha'], new MockAdapter('Alpha'))
  app.llm.registerAdapter(['mock-beta'], new MockAdapter('Beta'))

  const signal = new AbortController().signal
  const options = {
    provider: 'mock-alpha',
    model: 'mock-model',
    messages: [],
    signal,
  } as unknown as GenerateOptions

  for (const provider of ['mock-alpha', 'mock-beta']) {
    console.log(`\n========== provider: ${provider} ==========`)

    // 1. 直接消费适配器的 stream:逐 chunk 打印
    const adapter = (app.llm as any).adapters.get(provider)?.adapter as MockAdapter
    const assembler = new BlockAssembler()
    console.log('--- 原始 chunk 流 ---')
    for await (const chunk of adapter.stream({ ...options, provider })) {
      const brief = summarizeChunk(chunk)
      console.log(`  ${brief}`)
      assembler.push(chunk)
    }

    // 2. 组装结果:chunk 流 -> 完整消息
    console.log('--- BlockAssembler 组装结果 ---')
    for (const block of assembler.blocks()) {
      if (block.type === 'text') console.log(`  text: ${block.text}`)
      if (block.type === 'tool-call') console.log(`  tool-call: ${block.name}(${block.arguments})`)
    }
    console.log(`  usage: input=${assembler.usage?.inputTokens} output=${assembler.usage?.outputTokens}`)
    console.log(`  finish: ${JSON.stringify(assembler.finish)}`)
  }

  await app.fiber.dispose()
}

function summarizeChunk(chunk: StreamChunk): string {
  switch (chunk.type) {
    case 'block-start': return `block-start  #${chunk.index} ${chunk.blockType}`
    case 'text-delta': return `text-delta   #${chunk.index} +"${chunk.text}"`
    case 'tool-call-delta': return `tool-call-delta #${chunk.index} +"${chunk.argumentsDelta}"`
    case 'block-end': return `block-end    #${chunk.index} ${chunk.block.type}`
    case 'usage': return `usage        in=${chunk.usage.inputTokens} out=${chunk.usage.outputTokens}`
    case 'finish': return `finish       ${JSON.stringify(chunk.reason)}`
    default: return chunk.type
  }
}

main().catch((err) => { console.error(err); process.exit(1) })

作者本机 mock 示例输出(两个 provider 各跑一遍):


========== provider: mock-alpha ==========
--- 原始 chunk 流 ---
  block-start  #0 text
  text-delta   #0 +"你好,我是 Alpha 模型。"
  text-delta   #0 +" 我会流式输出。"
  block-end    #0 text
  block-start  #1 tool-call
  tool-call-delta #1 +"{"path":"/tmp/a.txt"
  tool-call-delta #1 +""}"
  block-end    #1 tool-call
  usage        in=12 out=30
  finish       {"kind":"tool-calls"}
--- BlockAssembler 组装结果 ---
  text: 你好,我是 Alpha 模型。 我会流式输出。
  tool-call: read_file({"path":"/tmp/a.txt"})
  usage: input=12 output=30
  finish: {"kind":"tool-calls"}

========== provider: mock-beta ==========
--- 原始 chunk 流 ---
  block-start  #0 text
  text-delta   #0 +"你好,我是 Beta 模型。"
  text-delta   #0 +" 我会流式输出。"
  block-end    #0 text
  block-start  #1 tool-call
  tool-call-delta #1 +"{"path":"/tmp/a.txt"
  tool-call-delta #1 +""}"
  block-end    #1 tool-call
  usage        in=12 out=30
  finish       {"kind":"tool-calls"}
--- BlockAssembler 组装结果 ---
  text: 你好,我是 Beta 模型。 我会流式输出。
  tool-call: read_file({"path":"/tmp/a.txt"})
  usage: input=12 output=30
  finish: {"kind":"tool-calls"}

这个示例说明三件事:第一,同一份消费代码(逐 chunk 打印 + BlockAssembler 组装)能够消费两个 mock adapter 的输出;第二,工具调用参数可以分多个 tool-call-delta 到达,再由组装器拼成完整 JSON 字符串;第三finish: {"kind":"tool-calls"} 告诉驱动器模型请求工具。它不测试真实 provider 的请求映射、鉴权、限流、模型目录或协议兼容性。

三、流式词汇表:7 个 chunk 变体的生命周期

StreamChunk 是适配器产出的原始流协议。实际联合类型有 7 个变体packages/llm/llm/src/types.ts:283-303):

/**
 * Raw streaming protocol emitted by adapters.
 * Block indexes correlate interleaved deltas, and `block-end` carries the
 * assembled block. Adapters emit usage before the terminal finish and nothing
 * afterward; tool arguments remain raw JSON strings. An adapter implementation
 * may throw, but `LlmRuntime.stream()` normalizes that failure to a terminal
 * `error` or `aborted` finish before exposing it to consumers.
 */
export type StreamChunk =
  | { type: 'block-start'; index: number; blockType: ContentBlockType }
  | { type: 'text-delta'; index: number; text: string }
  | { type: 'reasoning-delta'; index: number; text: string }
  | { type: 'tool-call-delta'; index: number; id: CallId; name?: string; argumentsDelta: string }
  | { type: 'block-end'; index: number; block: ContentBlock }
  | { type: 'usage'; usage: TokenUsage }
  | {
    type: 'finish'
    reason: FinishReason
    /** Adapter-private lossless-JSON state for replaying a successful response. */
    replayState?: unknown
  }

中文意思是:块 index 把交错增量关联起来,block-end 携带完整块;usage 必须在终止 finish 前发出,之后不得再有 chunk;工具参数始终保持原始 JSON 字符串。适配器抛错会由 LlmRuntime.stream() 规范化为终止的 erroraborted finish,再交给消费者。

类型干什么例子
block-start开一个内容块(文本/推理/工具调用){ type: 'block-start', index: 0, blockType: 'text' }
text-delta文本增量{ type: 'text-delta', index: 0, text: '你好' }
reasoning-delta适配器公开的 reasoning 内容增量同上;是否产生或展示取决于适配器与产品策略
tool-call-delta工具调用参数增量{ type: 'tool-call-delta', index: 1, id, name, argumentsDelta }
block-end闭块,携带完整块{ type: 'block-end', index: 0, block: {...} }
usagetoken 用量{ type: 'usage', usage: { inputTokens, outputTokens } }
finish结束原因`{ type: ‘finish’, reason: { kind: ‘stop’

7 个 StreamChunk 变体、AgentLoop 处理路径与 retry 边界

图:7 个 StreamChunk 变体按类别列举,不表示固定时序;源码中的 ReactLoopAgent.step() 在正常控制流里先追加 assistant/chunk,再交给 BlockAssembler。跨重启的保存与恢复仍取决于会话后端;底部为 normal retry 的事件顺序。

协议规则在中文适配器指南中直接写出:每个 block-start 必须有对应 block-endindex 从 0 递增;argumentsDelta 是可分片的原始 JSON 文本增量;usage 必须先于 finish,而 finish 必须是末个分片。

  1. 每个 block-start 必须有对应的 block-end
  2. index 从 0 递增,标识内容块顺序
  3. argumentsDelta 是原始 JSON 文本的增量,可以一个 chunk 给全、也可以分多个 chunk 给——demo 里就是两片拼一个 {"path":"/tmp/a.txt"}
  4. usage 必须在 finish 之前;finish 必须是最后一个 chunk

StreamChunk 类型本身只规定流协议,不规定每个消费者如何持久化。标准 ReactLoopAgent.step() 对一次 agent 请求的关键分支如下(packages/core/agent-loop/src/agent.ts:343-390):

      const assembler = new BlockAssembler()
      const chunkSeqs: number[] = []
      const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
      signal.throwIfAborted()
      for await (const chunk of stream) {
        signal.throwIfAborted()
        chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
        assembler.push(chunk)
      }
      signal.throwIfAborted()
      const finish = assembler.finish
      if (finish.kind === 'error' || finish.kind === 'aborted') {
        const action = await this.dispatch.waterfall(
          'agent/request-error', {
            turn,
            step,
            provider: request.provider,
            failure: finish.failure,
            retryPolicy: preparedCall?.retryPolicy,
            signal,
          },
          () => Promise.resolve<RequestErrorAction>(undefined),
        )
        signal.throwIfAborted()
        if (action?.kind !== 'retry') {
          throw new LlmError(finish.failure.message, finish.failure.code, finish.failure)
        }
        continue
      }

      const message = createAssistantMessage({
        content: assembler.blocks(),
        source: {
          provider: request.provider,
          model: request.model,
          ...assembler.replayState !== undefined ? { replayState: assembler.replayState } : {},
        },
      })
      this.session.append(
        'assistant/message',
        {
          turn,
          step,
          message,
          ...assembler.usage === undefined ? {} : { usage: assembler.usage },
        },
        { surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
      )

中文按执行顺序读:loop 每收到一个 chunk,assistant/chunk 写入会话并保存其 seq交给组装器;终止原因是 erroraborted 时,代码进入 agent/request-error waterfall,不会在该分支追加 assistant/message。只有正常完成时,组装出的内容块、可用 usage,以及引用的 chunk seq 一起写成最终消息。因此“逐 chunk 落日志、再组装消息”是标准 agent loop 的行为;直接调用 ctx.llm.stream() 的插件或脚本若需要重放,必须自行完成相应的持久化设计。

这也解释了两个消费者的职责:BlockAssembler 负责按 index 拼接增量并暴露 blocks()usagefinish;会话日志负责保存原始 chunk 与最终消息之间的引用关系。模型历史仍由 A3 的会话 surface 投影规则决定,不能把“同一条流”扩大成所有消息路径完全相同。

四、失败与重试:把失败当常态

概率系统的请求会失败:限流、超时、网络抖动、服务端 5xx。dsh 的 llm-retry 插件把“是否重试、等多久、怎样收尾”拆成三层。

第一,指数退避 + 抖动。 延迟计算不是概念公式,源码如下(packages/llm/llm-retry/src/index.ts:58-63):

function localDelay(config: ResolvedRetryPolicy, retry: number, random: () => number): number {
  const exponent = Math.min(retry - 1, 1024)
  const exponential = Math.min(config.initialDelayMs * 2 ** exponent, config.maxDelayMs)
  const jitter = 1 - config.jitterRatio + 2 * config.jitterRatio * random()
  return Math.min(exponential * jitter, config.maxDelayMs)
}

中文按顺序读:重试指数先限制在 1024;指数延迟先限制到 maxDelayMs;随后乘以抖动;返回值再限制一次 maxDelayMs。如果 provider 给出有效且不超上限的 providerRetryAfterMs,恢复流程会优先用它;超过上限时,normal 策略会把决策交给下游,而不是无条件重试。

第二,调度顺序可在日志中看见。 backoff() 先追加 llm/retry,等待可取消延迟,随后才追加 llm/retry-startedpackages/llm/llm-retry/src/index.ts:111-153):

  async function backoff(
    agent: Agent,
    turn: number,
    step: number,
    failure: LlmFailure,
    provider: string,
    policy: ResolvedRetryPolicy,
    policyKey: string,
    retry: number,
    retryId: RetryId,
    delayMs: number,
    signal: AbortSignal,
  ): Promise<RequestErrorAction> {
    const fusedSignal = AbortSignal.any([signal, lifetime.signal])
    if (fusedSignal.aborted) return
    const eventData: LlmRetryEventData = policy.mode === 'normal'
      ? {
        retryId,
        turn,
        step,
        provider,
        mode: policy.mode,
        policyKey,
        retry,
        maxRetries: policy.maxRetries,
        delayMs,
        failure,
      }
      : {
        retryId,
        turn,
        step,
        provider,
        mode: policy.mode,
        policyKey,
        retry,
        delayMs,
        failure,
      }
    agent.session.append('llm/retry', eventData)
    if (!await cancellableDelay(delayMs, fusedSignal)) return
    agent.session.append('llm/retry-started', { retryId, turn, step, retry })
    return { kind: 'retry' }
  }

中文意思是:被调度的重试先成为会话事件,再进入可取消等待;等待完成后记录 llm/retry-started,然后返回 { kind: 'retry' } 决策。这个函数只能证明事件追加与返回决策的顺序,不能证明 provider I/O 已经开始或完成;是否能在进程崩溃后恢复,还取决于所用持久化后端是否保存并恢复这些事件。

第三,是否接管失败由 waterfall 和 policy 共同决定。 normal 策略的决策分支如下(packages/llm/llm-retry/src/index.ts:177-207):

    } else if (!policy.retryableCodes.includes(failure.code)) {
      return next()
    }

    const policyKey = retryPolicyKey(policy)
    const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> =>
      event.type === 'llm/retry'
      && event.data.turn === turn
      && event.data.step === step
      && event.data.provider === provider
      && event.data.policyKey === policyKey,
    )
    const previousRetry = priorPolicyRetry?.data.retry ?? 0
    if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next()
    const retry = previousRetry + 1
    const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID())
    let delayMs: number
    if (failure.providerRetryAfterMs !== undefined
      && Number.isFinite(failure.providerRetryAfterMs)
      && failure.providerRetryAfterMs > 0) {
      if (failure.providerRetryAfterMs > policy.maxDelayMs) {
        if (policy.mode === 'normal') return next()
        delayMs = localDelay(policy, retry, random)
      } else {
        delayMs = failure.providerRetryAfterMs
      }
    } else {
      delayMs = localDelay(policy, retry, random)
    }

    return backoff(agent, turn, step, failure, provider, policy, policyKey, retry, retryId, delayMs, signal)

这里的 return next() 不是“立刻放弃”的同义词,而是把决定交给 waterfall 的下游 listener 或默认路径。对 normal 而言,失败 code 不在 retryableCodes、历史重试数已达 maxRetries、或 provider 指定的等待超过 maxDelayMs 时,llm-retry 都不自行排期;满足条件才调用上一节的 backoff()always 是另一条不受 maxRetries 约束的分支,不能把它和 normal 的上限混为一谈。

它接入 Agent 事件域的源码也很短(packages/llm/llm-retry/src/index.ts:210-225):

  const disposeListener = ctx.on('agent/request-error', (
    payload,
    next: () => Promise<RequestErrorAction>,
  ) => {
    // A waterfall may have captured this callback before its registration was
    // removed. Lifetime cancellation must prevent that stale callback from
    // entering a downstream policy after disposal.
    if (lifetime.signal.aborted) return Promise.resolve<RequestErrorAction>(undefined)
    return track(recover(payload, next))
  })

  ctx.effect(() => async () => {
    disposeListener()
    lifetime.abort(new Error('llm-retry plugin disposed'))
    await Promise.allSettled([...active])
  }, 'llm-retry: abort and drain active recovery')

英文注释的意思是:waterfall 可能在 listener 被移除前就捕获了回调,所以插件卸载后必须取消生命周期,阻止旧回调继续进入下游策略。也就是说,重试不是“捕获异常后随手再发一次”,而是一个可取消、可卸载的插件决策链;但事件已经写入会话并不单独保证进程崩溃后的恢复,后者还必须由所选持久化后端保存和重建。

五、token 计量:提供方样本与启发式估算

不能把 dsh 的计量简化成“有 usage 就精确、没有就按字符数补一个”。token-meter 同时处理提供方报告的用量样本固定启发式的上下文估算,两者用途不同。它从两个事件形态读取 usage(packages/llm/token-meter/src/usage-projection.ts:74-80):

/** The usage a chunk or finalized message reports for its step, if any. */
const usageOf = (event: SessionEvent): TokenUsage | undefined =>
  event.type === 'assistant/chunk' && event.data.chunk.type === 'usage'
    ? event.data.chunk.usage
    : event.type === 'assistant/message'
      ? event.data.usage
      : undefined

上一节的 AgentLoop 源码已经显示了这两条路径:流中出现 usage 时,它是 assistant/chunk 的一部分;正常完成后,组装器的 usage 也会被放进最终 assistant/message。同一个 (turn, step) 的最终消息样本会替换早到的 chunk 样本而不是重复相加;如果请求后来失败,已经写入的 usage chunk 仍可被投影记录。这是“尽量保留提供方读数”,不是把任何一个数字直接当成账单。

当没有可复用的匹配提供方样本时,meter 才用固定启发式为当前表层和请求 envelope 估价。官方中文 README 把复用条件写得很严格(packages/llm/token-meter/README.zh.md:20):

只有当最新成功调用的规范请求 envelope 与已测量 envelope 匹配,且其总量不低于该调用的完整启发式锚点时,才会复用提供方用量;否则会对当前 envelope 与表层进行完整估算。

估算器不是某家模型的 tokenizer,源码就是固定密度规则(packages/llm/token-meter/src/estimate.ts:12-58):

/** Fixed text-density estimate used until exact tokenization is needed. */
const CHARS_PER_TOKEN = 4

/** Per-block structural overhead for JSON framing and type tags. */
const BLOCK_OVERHEAD = 4

/** Role-field framing overhead added to every priced message. */
export const ROLE_OVERHEAD = 4

/**
 * Price content blocks recursively under the fixed density heuristic.
 * @param blocks - content blocks to price without mutation.
 * @returns heuristic tokens including per-block structural overhead.
 */
export function estimateContent(blocks: readonly ContentBlock[]): number {
  let tokens = 0
  for (const block of blocks) {
    switch (block.type) {
      case 'text':
      case 'reasoning':
        tokens += Math.ceil(block.text.length / CHARS_PER_TOKEN) + BLOCK_OVERHEAD
        break
      case 'tool-call':
        tokens += Math.ceil(block.name.length / CHARS_PER_TOKEN)
          + Math.ceil(block.arguments.length / CHARS_PER_TOKEN)
          + BLOCK_OVERHEAD
        break
      case 'tool-result':
        tokens += estimateContent(block.content) + BLOCK_OVERHEAD
        break
      default:
        // ContentBlockMap is merge-extensible; unknown blocks retain a
        // conservative structural JSON price under the fixed heuristic.
        tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / CHARS_PER_TOKEN)
    }
  }
  return tokens
}

/**
 * Heuristically price one model-visible message.
 * @param message - message to price without mutation.
 * @returns content and role-framing tokens under the fixed heuristic.
 */
export function estimateMessage(message: Message): number {
  return estimateContent(message.content) + ROLE_OVERHEAD
}

中文意思是:文本和 reasoning 文本按“4 个字符约等于 1 token”估计;每个内容块再加 4 个结构开销;工具调用按名称和原始参数字符串计价;未知扩展块按 JSON 长度保守估价。每条消息还会加 ROLE_OVERHEAD。因此这是用于上下文压力、表层组成,以及供压缩消费者读取的参考近似数,不是提供方计费账单;官方文档明确提醒 CJK 文本和 JSON schema 可能被严重低估,且占用率不是框架的门控输入。

六、代价与边界

三个代价:

第一,适配器是翻译层,不是万能层。 中文适配器指南对 GenerateOptions 的约束原文如下(docs/user/develop/practice/llm-adapter.zh.md:114):

适配器必须将支持的字段映射到具体 API;如果无法支持某个字段,应抛出带稳定 code 的 LlmError,不得静默丢弃。

这是一条给适配器作者的实现要求:对 dsh 已声明的字段,要么明确映射,要么以稳定错误码失败。它不自动证明某个 provider 的专有功能已有统一表示,也不替代真实端点的映射测试;接入新服务时仍要逐字段验证。

第二,词汇表和适配器 API 仍在演进。 源码基线的 README 直接写道(README.zh.md:10-12):

DeepSeek Harness 目前处于 开发者预览 阶段,正在快速迭代。未来将出现破坏兼容性的变更。

因此适配器作者应锁定已验证的 dsh 版本,并在升级时重新检查 StreamChunkGenerateOptions、请求路由和 provider SDK 的兼容性;不能把本文的 47f9438 结论承诺为长期稳定 API。

第三,重试是双刃剑。 退避策略保护上游,但重试也意味着延迟叠加——实际等待上限由 policy、provider 给出的 providerRetryAfterMs 建议、取消信号和下游 waterfall 决定。应把重试预算和用户可感知的等待一起设计,而不是把 maxRetries 当作唯一延迟上限。

七、决策表

设计问题方案 A(简单做法)方案 B(dsh 的机制)选择理由与边界
多提供方接入在核心流程写 provider 分支适配器路由 + 统一词汇表核心消费者不必理解 wire protocol;适配器仍负责凭据、映射和能力差异
流式协议各家 SDK 各传各的StreamChunk 统一协议标准 AgentLoop 可把原始 chunk 记录并组装;其他直接消费者仍要设计自身持久化
失败处理catch 后固定重试policy + waterfall + 受限退避按稳定 failure code、预算与取消信号决策;会话事件顺序不替代持久化恢复后端
参数不支持静默忽略适配器抛稳定 code 的 LlmError这是适配器实现约束,需用真实端点测试,而非框架自动识别全部差异
token 计量把 API 返回直接当完整账单提供方样本 + 匹配条件 + 固定启发式可观察上下文压力;估算对 CJK、JSON 等内容并不精确
切换路由在核心消费代码改分支profile 选择已注册 adapter 路由可避免改动统一消费逻辑;目标 provider 仍须完成配置并支持当前请求

八、系列路线

下一篇进入能力 seam 与安全:Service/Provider/Consumer 三角色、fs/shell/subagent seam、沙箱与 landlock——自主性和安全性是零和的吗?

下一篇:能力 seam 与沙箱:自主性和安全性不是零和


FAQ

Q:dsh 怎么接入新模型? 实现 LlmAdapter.stream():把 dsh 的完整请求映射到提供方 API,再把响应映射为 StreamChunk 流;随后用 ctx.llm.registerAdapter(providers, adapter) 注册。provider 选择已注册路由,model id 交给该适配器处理。凭据、请求映射、模型目录和不支持字段仍须由适配器与 profile 配置处理,不能承诺任意提供方只改一个字段即可运行。

Q:StreamChunk 有哪些类型? 7 个变体:block-starttext-deltareasoning-deltatool-call-deltablock-endusagefinish。每个 block-start 要有对应 block-endindex 从 0 递增;usagefinish 前,finish 是最后一个分片。

Q:流式 chunk 是怎么变成消息的? 在标准 ReactLoopAgent 中,每个 chunk 先追加为 assistant/chunk,再喂给 BlockAssembler;正常结束后,组装出的内容块和可用 usage 追加为 assistant/message,并记录所引用的 chunk seq。直接调用 ctx.llm.stream() 的其他消费者要自行决定是否、如何记日志。

Q:dsh 的模型重试策略是什么? normal 策略仅对 retryableCodes 中的失败、且未超过 maxRetries 时排期;延迟先指数增长并受 maxDelayMs 限制,再加入抖动并再次限制。排期事件先写入会话、再进入可取消等待;进程重启后能否继续仍取决于所用会话持久化与恢复后端。

Q:适配器必须支持所有请求字段吗? 官方适配器指南要求:不能兑现某个字段时,适配器应抛出带稳定 code 的 LlmError,而不是静默丢弃。这个要求需要由适配器作者用映射与测试落实,不是框架自动识别所有提供方差异。

Q:token 用量从哪里来? 适配器可在 usage chunk 中报告提供方用量;token-meter 会把该样本与最终 assistant/message 用量纳入投影。没有可复用的匹配提供方样本时,它按固定启发式估算表层、系统提示词和工具 schema;这是上下文压力近似值,不是计费账单。


互动模块

① 站队:模型接入,“统一词汇表 + 适配器”(dsh 模式)和"直接用各家 SDK + 胶水层"(多数项目做法),你更倾向哪种?A. 适配器 seam 是正解 B. 胶水层简单直接,词汇表是过度设计 C. 取决于要接多少家

② 征集:你在生产里被"模型悄悄忽略参数"坑过吗——文档说支持、实际静默丢弃的那种?最后怎么发现的?评论区分享,我会在能力 seam 篇里结合真实案例展开。

③ 转发:如果你身边有人正准备给 dsh 写第一个模型适配器,把这篇转给他——适配器 seam 图值得收藏。