这是《DeepSeek Harness 权威指南 》系列的第 6 篇。 本文源码基线为 deepseek-harness @
47f9438(v0.1.0-rc.5)。源码和中文文档结论在首次出现处说明来源;本机 mock demo 只说明该脚本、命令和环境下的行为。
dsh 用适配器把模型提供方的 API 格式、协议和鉴权差异隔离在插件层。 核心代码面对的是统一消息和 StreamChunk 词汇表,而不是某家 SDK。本文的本机 demo 注册了 mock-alpha 和 mock-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 的鉴权、模型或请求映射可用。

图:在流式调用路径上,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() 规范化为终止的 error 或 aborted 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: {...} } |
usage | token 用量 | { type: 'usage', usage: { inputTokens, outputTokens } } |
finish | 结束原因 | `{ type: ‘finish’, reason: { kind: ‘stop’ |

图:7 个 StreamChunk 变体按类别列举,不表示固定时序;源码中的 ReactLoopAgent.step() 在正常控制流里先追加 assistant/chunk,再交给 BlockAssembler。跨重启的保存与恢复仍取决于会话后端;底部为 normal retry 的事件顺序。
协议规则在中文适配器指南中直接写出:每个 block-start 必须有对应 block-end;index 从 0 递增;argumentsDelta 是可分片的原始 JSON 文本增量;usage 必须先于 finish,而 finish 必须是末个分片。
- 每个
block-start必须有对应的block-end index从 0 递增,标识内容块顺序argumentsDelta是原始 JSON 文本的增量,可以一个 chunk 给全、也可以分多个 chunk 给——demo 里就是两片拼一个{"path":"/tmp/a.txt"}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,再交给组装器;终止原因是 error 或 aborted 时,代码进入 agent/request-error waterfall,不会在该分支追加 assistant/message。只有正常完成时,组装出的内容块、可用 usage,以及引用的 chunk seq 一起写成最终消息。因此“逐 chunk 落日志、再组装消息”是标准 agent loop 的行为;直接调用 ctx.llm.stream() 的插件或脚本若需要重放,必须自行完成相应的持久化设计。
这也解释了两个消费者的职责:BlockAssembler 负责按 index 拼接增量并暴露 blocks()、usage、finish;会话日志负责保存原始 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-started(packages/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 版本,并在升级时重新检查 StreamChunk、GenerateOptions、请求路由和 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——自主性和安全性是零和的吗?
FAQ
Q:dsh 怎么接入新模型?
实现 LlmAdapter.stream():把 dsh 的完整请求映射到提供方 API,再把响应映射为 StreamChunk 流;随后用 ctx.llm.registerAdapter(providers, adapter) 注册。provider 选择已注册路由,model id 交给该适配器处理。凭据、请求映射、模型目录和不支持字段仍须由适配器与 profile 配置处理,不能承诺任意提供方只改一个字段即可运行。
Q:StreamChunk 有哪些类型?
7 个变体:block-start、text-delta、reasoning-delta、tool-call-delta、block-end、usage、finish。每个 block-start 要有对应 block-end;index 从 0 递增;usage 在 finish 前,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 图值得收藏。
