Files
deepseek-harness/docs/subsystems/llm-streaming.zh.md
T
Tianyi Cui aa0ca6c836 docs: anchor each subsystem page to its package group; make group READMEs thin tables
core.md read as a type grab-bag: LLM wire vocabulary up front, the agent/loop story buried, and no correspondence to packages/core. It now opens on the packages/core control spine — the package-by-package loop map with a Page column into session/system-prompt/tools/scope — and keeps only what the spine group declares plus the repo-wide patterns: the Agent handle with its delivery/cancellation/interception contracts, the SessionEvent envelope, branded ids, the …Map pattern. The conversation vocabulary (Message/ContentBlock, the model request, adapters — 17 type-equiv blocks) moves to llm-streaming.md, which now declares packages/llm end-to-end; the duplicate ContentBlockMap paste near its seam section folds into the moved section, and the manifest, LINK_MAP, README table rows, website label (Core data structures → Core), and inbound anchors follow.

Every packages/<group>/README pair is now a thin front door in one shape: a why-first intro (bash's seam-pattern-first paragraph rewritten as 'shell execution for the agent'), the package table, and a closing pointer to the owning docs/subsystems page — the bash-style table stays the load-bearing middle. Load-bearing trailing paragraphs relocate rather than vanish: the fs no-timeout rationale becomes a filesystem.md section (both languages), session's four sectioned tables merge into one 12-row table, examples' legacy-bin H2 collapses to a pointer at jsonrpc-demo's README, and design rationale that already lives in an Agent Note or subsystem page is now linked instead of restated. All 40 pair records re-recorded.
2026-08-09 01:32:39 +08:00

39 KiB
Raw Blame History

LLM(大语言模型)流式输出

English | 中文

packages/llm 的对话与流式输出词汇:每个请求与持久历史共享的 Message/ContentBlock 形状、完整组装的模型请求、原始 StreamChunk 协议、每个适配器必须遵守的适配器契约(adapter contract),以及共享的 assembler。核心主干在每个轮次持有并记录这些值;本页声明它们。

源码:packages/llm/llm/src/types.ts

内容块与消息

一段对话由 Message 组成;一条消息是一个类型化内容块的数组。块的联合类型从 ContentBlockMap 派生。

源码:packages/llm/llm/src/types.ts

/**
 * Merge-extensible content blocks keyed by `type`. New core blocks must land
 * with adapter, UI, and compaction support.
 */
interface ContentBlockMap {
  'text': TextBlock
  'reasoning': ReasoningBlock
  'tool-call': ToolCallBlock
  'tool-result': ToolResultBlock
}

各块接口(完整字段见源码):TextBlocktext)、ReasoningBlockthinking,区别于可见文本)、ToolCallBlockid: CallIdname、原始 JSON arguments)、ToolResultBlocktoolCallId、嵌套 content: ContentBlock[]isError?)。ContentBlock = ContentBlockMap[ContentBlockType]。核心集仅限于每条交付路径都尊重的块——多模态内容(图像、音频等)没有核心块类型;需要的功能通过可合并扩展的 map 添加,同时提供适配器/UI/压缩支持。

源码:packages/llm/llm/src/message.ts

Message 是一个带标识且不可变的角色/来源/内容值。模型产生的 assistant 消息会在其来源中携带提供方/模型所有权与可选的适配器私有回放元数据:

/** Provider ownership and adapter-private replay data for an assistant message. */
interface AssistantProvenance {
  /** Provider route that produced the message. */
  provider: string
  /** Provider model id that produced the message. */
  model: string
  /**
   * Lossless-JSON adapter state needed to replay the provider response.
   * `LlmService` exposes it to a target adapter only when that adapter instance
   * currently owns both this historical provider and the target provider.
   */
  replayState?: unknown
}
/** One immutable message representation shared by delivery, durable history, and model requests. */
interface Message {
  /** Stable identity preserved across every representation boundary. */
  readonly id: MessageId
  /** Provider-neutral conversation role. */
  readonly role: 'system' | 'user' | 'assistant'
  /** Exact model-facing blocks. */
  readonly content: ContentBlock[]
  /** Required producer provenance. */
  readonly source: MessageSource
}

消息来源本身也是一个可合并扩展的和类型:

/**
 * Where a message (or injected content) came from.
 * Merge-extensible sum type — plugins add their own `kind`s.
 */
interface MessageSourceMap {
  user: { kind: 'user' }
  plugin: { kind: 'plugin'; plugin: string }
  model: ModelMessageSource
  tool: ToolMessageSource
}

StreamChunk:原始协议

一个流式响应交错包含多种类型的块(文本、推理(reasoning)、多个工具调用)。index 将每个 delta 关联到其所属块;block-end 携带完整组装好的 ContentBlock,消费方无需自行重新组装 delta。这是一个封闭的可辨识联合类型:对 typeswitchassertNever 结尾,因此新增变体会在每个必须处理它的消费方处触发编译错误。

/**
 * 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. Failures either throw or
 * end with `error`/`aborted`, and consumers must handle both paths.
 */
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
  }

LlmFailure

每个抛出的失败或最终适配器的带内失败都会规范化为一种可序列化、提供方无关的 payload。providerRetryAfterMs 是经校验、由提供方请求的正数延迟,而不是重试决策;ProviderRequestId 是用于诊断的不透明品牌字符串。

/** Serializable provider-boundary facts; policy decides whether they are retryable. */
interface LlmFailure {
  /** Human-readable provider or transport failure. */
  readonly message: string
  /** Stable provider-neutral machine-routing code. */
  readonly code: string
  /** HTTP status observed at the provider boundary, when available. */
  readonly status?: number
  /** Provider-requested delay in milliseconds, when valid and available. */
  readonly providerRetryAfterMs?: number
  /** Opaque provider-issued request identifier for diagnostics. */
  readonly requestId?: ProviderRequestId
}

适配器契约

每个适配器必须遵守以下规则,每个消费方可以依赖它们:

  • usagefinish 之前,finish 之后不再有任何分片。 将两者都推迟到提供方的流结束标记,这样尾部的 usage-only 分片就不会违反顺序。
  • 工具调用的 arguments 全程保持原始 JSON 字符串。 部分片段通过 argumentsDelta 流式传输;如果提供方返回的是已解析的对象,适配器在 block-end 时重新序列化为字符串。
  • 两条受支持的错误路径,一种事实形状。 失败可以从 stream() 抛出(传输/协议错误),或者finish {kind:'error'|'aborted', failure} 结束流(无法在流中途抛异常的适配器用它表示提供方带内错误)。LlmError.failure 携带同一个 LlmFailure。最终适配器边界保留被抛出的确切 Error 对象,并将不可变事实以及实际服务注册所对应的不可变重试策略关联到该调用;agent loop(智能体循环)关闭失败步骤,再把错误、事实、不可变的先前已重试失败事实、实际服务策略和轮次信号提供给 agent/request-error。处理该错误的 listener 在其 await 的修复完成后返回 { kind: 'retry' };若未恢复,结构化失败会成为轮次错误,并且该次尝试不会提交正常 assistant 消息或工具副作用。
  • 一次适配器调用就是一次提供方尝试。 适配器禁用库重试。agent 层恢复会打开另一个持久、带编号的轮次;直接调用 ctx.llm.stream() 的调用方仍然只尝试一次。
  • 提供方停顿在传输层受到时限约束。 两个已交付的远程适配器都暴露正数且有限的 streamIdleTimeoutMs,默认五分钟。watchdog 只在 iterator next() 尚未完成时启动,整个请求使用同一个稳定 signal,把自身到期映射为 TIMEOUT,并把更早发生的调用方中止保留为 ABORTED
  • 上下文溢出只有一个规范 code。 两个 DeepSeek 适配器都通过 isContextWindowExceededError() 对提供方的显式细节分类并暴露 CONTEXT_WINDOW_EXCEEDED,无论失败以抛出的 HTTP LlmError 还是带内 finish error 到达。消费方按 code 路由,绝不依赖提供方文本。
  • 空 completion 是可重试错误,而不是静默的成功结果。 两个适配器都把没有携带任何内容块的终止性 stop 结束映射为携带规范 EMPTY_RESPONSE code 的 finish {kind:'error'}dsh-llm-retry 默认会重试它;详见空模型响应可重试
  • 每个提供方 HTTP 请求都携带应用归属头。 适配器发送 attributionHeaders()(见下文)作为 User-Agent 基线,并通过协议级测试加以证明(mock 服务器断言收到的 header,或对基于库的适配器使用库的 header 钩子)。
  • 回放状态归适配器所有。 成功的 finish 可以携带重建提供方原生响应所需的无损 JSON 状态。循环会将其与组装后的 assistant 消息一起存储。后续请求中,仅当历史提供方与目标提供方当前注册到完全相同的适配器实例时,LlmService 才会传递该状态。该适配器负责校验状态并拥有所有跨模型或跨提供方转换;其他适配器只会收到提供方无关的内容与 provenance,不会收到私有状态。

该契约由两个有意保持独立的实现锁定:dsh-llm-deepseek(直接 fetchSSEServer-Sent Events)分帧经由 eventsource-parser)和 dsh-llm-pi-ai(通过 @earendil-works/pi-ai 实现的通用多提供方适配器)。基于库的适配器覆盖 finish 分片错误路径,而传输边界测试证明每个空闲 watchdog 都会停止其实际请求。

ResolvedRetryPolicy

提供方配置会在路由注册前解析为不可变的可辨识联合。normal mode 携带 mode: 'normal'、有限的 maxRetriesretryableCodes,以及必填的 initialDelayMsmaxDelayMsjitterRatioalways mode 携带 mode: 'always' 和相同的必填退避字段,但没有有限上限。LlmService.providerRetryPolicy(provider) 返回当前注册的值,并在适配器省略策略时提供 normal 默认值;调用进入最终适配器边界后,llmRetryPolicyOf(stream) 返回为其提供服务的确切注册所捕获的值,因此之后释放或替换路由都无法改变进行中失败的恢复策略。可选输入形状由生成的配置目录规定。

AppIdentity:应用归属

每个适配器都会向提供方发送的静态公开应用标识(packages/llm/llm/src/attribution.ts)。attributionHeaders(identity?) 只把它映射到标准 User-Agent header;该契约有意不支持 OpenRouter 特有的应用归属 header。默认 APP_IDENTITY 从包(package manifest(元数据清单)获取版本;每个字段都是公开产品事实——不含 secret、路径、会话 id 或逐用户标识,且任何逐请求信息都不得影响这些值。设计理由见强制 User-Agent 归属

/**
 * Static public application identity sent to LLM providers.
 *
 * Every field is a public product fact, safe on every request: no secrets,
 * local paths, session ids, prompt text, or per-user identifiers belong here,
 * and nothing per-request may influence the values.
 */
interface AppIdentity {
  /** `User-Agent` product token (lowercase, hyphenated). */
  product: string
  /** Product version; sourced from package metadata, never hand-copied. */
  version: string
  /** Public home URL of the app, used as the `User-Agent` comment. */
  url: string
}

TokenUsage

逐调用 token 记账。各计数互不重叠inputTokens 只包含未缓存输入;缓存输入单独报告,计费输入是三者之和。若提供方把缓存命中折入单一提示词总数(如 DeepSeek 的 prompt_tokens),适配器会再将其扣除。reasoningTokens 存在时只是信息性细节,已经包含在 outputTokens 中;汇总时不得重复相加。

/**
 * Token accounting for one model call (cache fields are optional).
 *
 * Counts are DISJOINT: `inputTokens` is uncached input only; cached input is
 * reported separately as `cacheReadTokens`/`cacheWriteTokens` (billed input =
 * sum of the three). Adapters whose providers fold cache hits into a total
 * prompt count (DeepSeek's `prompt_tokens`) subtract them out.
 */
interface TokenUsage {
  inputTokens: number
  outputTokens: number
  cacheReadTokens?: number
  cacheWriteTokens?: number
  reasoningTokens?: number
}

BlockAssembler

BlockAssemblerpackages/llm/llm/src/assembler.ts)是唯一的共享实现,负责把 StreamChunk 流折叠回 ContentBlock、usage、结束原因与回放状态。循环在记录原始分片的同时,把同一批分片送入 assembler,再将组装后的 assistant 内容连同其提供方/模型 provenance 一起存储。需要组装结果、又不想重新实现 fold 的消费方使用它。

/**
 * Incrementally assembles raw {@link StreamChunk}s into complete
 * {@link ContentBlock}s and a final assistant {@link Message}.
 *
 * The agent loop feeds it while logging raw chunks for replay fidelity, then
 * reads `blocks()` / `message()` / `usage` / `finish` once the stream ends.
 *
 * Tolerant of delta-only protocols (no block-start/end); deltas arriving for
 * an index already closed by `block-end` are ignored (malformed stream) so a
 * misbehaving adapter cannot grow memory or corrupt a completed block.
 */
declare class BlockAssembler {
  /**
   * Feed one chunk into the assembly state.
   * @param chunk - the next raw chunk, in stream order.
   */
  push(chunk: StreamChunk): void;
  /**
   * Assemble all blocks seen so far, in stream order.
   * @returns one block per seen index; an open block assembles from its
   *   accumulated deltas (an unknown block type never closed by `block-end` throws).
   */
  blocks(): ContentBlock[];
  /** Usage from the `usage` chunk; undefined until one arrives. */
  get usage(): TokenUsage | undefined;
  /** Finish reason from the `finish` chunk; `{kind: 'stop'}` when the stream ended without one. */
  get finish(): FinishReason;
  /** Adapter-private replay state from the terminal finish chunk, if any. */
  get replayState(): unknown;
  /**
   * The assembled assistant message.
   * @param source - producer attribution for the assembled message.
   * @returns a frozen assistant-role message over `blocks()` (same open-block assembly rules).
   */
  message(source: MessageSource = { kind: 'plugin', plugin: 'dsh-llm/assembler' }): Message;
}

模型请求

一次模型调用是一个完全组装好的 GenerateOptions。适配器以原始 StreamChunk 流作答;消费方用 BlockAssembler 组装它。

源码:packages/llm/llm/src/types.ts

提供方与模型发现使用小型、提供方无关的描述符。模型目录仅供参考:路由仍以已注册提供方为键,适配器也可以接受未列出的模型 id。

注册适配器会返回一个句柄:既是释放器,也带有原子的路由替换——路由集合由用户配置决定的插件正需要它。

/**
 * What {@link LlmService.registerAdapter} returns: the disposer, plus an
 * atomic route replacement for the same adapter instance.
 */
interface AdapterRegistrationHandle {
  /** Release every route this registration currently holds. */
  (): void
  /**
   * Replace this registration's routes with `providers`, keeping the same
   * adapter instance. The candidate set is validated in full first — a
   * conflict with another adapter, an invalid name, or bad provider metadata
   * throws and leaves the current routes untouched — and the swap itself is
   * one synchronous section, so no request can observe a gap. An empty array
   * is legal here (a settings section that emptied holds zero routes while
   * staying registered), unlike an empty initial registration.
   *
   * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration
   * has been released: its routes are gone and its disposer has already run,
   * so anything registered afterwards would have no owner left to release it.
   * @param providers - the complete next route set for this registration.
   */
  replace(providers: string[]): void
}
/** Display metadata for one registered provider route. */
interface LlmProviderInfo {
  /** Provider route key used by {@link GenerateOptions.provider}. */
  id: string
  /** Human-readable provider name for selectors and diagnostics. */
  name: string
}

适配器插件还会通过 registerConfigurableProviders() 声明哪些路由可以运行,并指明每条路由的用户设置分节,使配置界面能在任何路由注册之前就呈现休眠的提供方。

/**
 * One provider route an adapter plugin can activate through configuration,
 * whether or not the route is currently registered. Configuration surfaces
 * merge this directory with `listProviders()` to offer every configurable
 * provider alongside its live/dormant state.
 */
interface LlmConfigurableProvider {
  /** Provider route key this entry activates when configured. */
  provider: string
  /** Human-readable provider name for configuration surfaces. */
  displayName: string
  /** User-settings namespace whose section configures this provider. */
  settingsNs: string
  /**
   * Path from that namespace's section root to this provider's profile
   * object; empty when the whole section is the profile.
   */
  settingsPath: readonly string[]
}
/** One adapter-discovered model; catalog membership is advisory, not request validation. */
interface LlmModelInfo {
  /** Provider route that owns this model entry. */
  provider: string
  /** Model id passed to {@link GenerateOptions.model}. */
  id: string
  /** Human-readable model name for selectors. */
  name: string
  /** Optional user-facing distinction from otherwise similar models. */
  description?: string
}

对正确性敏感的元数据与参考目录分开解析,并归服务该确切路由的适配器所有。上下文容量、适配器调用默认值和推理选项共用同一个确切模型结果,消费方因而无需重复执行权威模型解析。

/** Provider-owned context capacity for one exact provider/model route. */
interface LlmModelContext {
  /** Maximum combined request and response context in tokens. */
  contextWindow: number
}

推理强度是另一项针对确切路由的能力。核心为标识符添加品牌类型,但不枚举其值;有序集合、展示名称和可选的部署默认值均由各适配器持有。

/** Adapter-owned identifier for one model's selectable reasoning effort. */
type ReasoningEffortId = Branded<'ReasoningEffortId'>
/** Display metadata for one adapter-owned reasoning effort. */
interface LlmReasoningEffortInfo {
  /** Opaque stable value accepted by {@link GenerateOptions.reasoningEffort}. */
  id: ReasoningEffortId
  /** Human-readable effort name for selectors and diagnostics. */
  name: string
  /** Optional user-facing distinction from otherwise similar efforts. */
  description?: string
}
/** Selectable reasoning efforts for one exact provider/model route. */
interface LlmModelReasoningInfo {
  /** Supported efforts in adapter-preferred display order. */
  efforts: readonly LlmReasoningEffortInfo[]
  /**
   * Adapter-configured default materialized into requests when callers omit
   * an effort. Absence preserves the provider's own default.
   */
  defaultEffort?: ReasoningEffortId
}
/** Exact-route model metadata resolved by its owning adapter. */
interface LlmResolvedModelInfo extends LlmModelInfo {
  /** Provider-owned context capacity when known. */
  context?: LlmModelContext
  /** Adapter-configured per-request output cap materialized when callers omit one. */
  defaultMaxTokens?: number
  /** Adapter-owned selectable reasoning levels when exposed. */
  reasoning?: LlmModelReasoningInfo
}
/** A single model request, fully assembled. */
interface GenerateOptions {
  /** Registered provider route selecting the adapter instance. */
  provider: string
  model: string
  /** Adapter-owned reasoning effort selected for this exact model. */
  reasoningEffort?: ReasoningEffortId
  /**
   * Ordered conversation messages, exactly as the provider sees them (after
   * the `system` slot). A loop-built request assembles them as
   * the derived history (dsh-agent-loop); a hand-built one-shot passes any list.
   */
  messages: Message[]
  /** System prompt text (adapters map to the provider's system slot). */
  system?: string
  /** Tool schemas (adapters map to the provider's `tools` field). */
  tools?: ToolSchema[]
  temperature?: number
  maxTokens?: number
  /**
   * Stop sequences: generation halts as soon as the model produces any one of
   * these strings (adapters map to the provider's stop field, e.g. OpenAI
   * `stop`). The stop string itself is not included in the output.
   */
  stop?: string[]
  signal?: AbortSignal
  /**
   * Session identity stamped by the loop for listener routing. Adapters ignore
   * it; replay uses it to keep concurrent parent and child cursors independent.
   */
  sessionId?: Branded<'SessionId'>
  /**
   * Provider-neutral classification for an auxiliary model call. Adapters may
   * map the purpose to model-hidden transport metadata or purpose-specific
   * generation policy. Ordinary conversation requests leave it unset.
   */
  purpose?: 'compaction' | 'session-title'
}

模型响应为何停止由可合并扩展的原因表示。提供方终态失败携带流式契约的 LlmFailure

/**
 * Why a model response stopped.
 * Merge-extensible so adapters can surface provider-specific reasons.
 */
interface FinishReasonMap {
  'stop': { kind: 'stop' }
  'tool-calls': { kind: 'tool-calls' }
  'max-tokens': { kind: 'max-tokens' }
  'aborted': { kind: 'aborted'; failure: LlmFailure }
  'error': { kind: 'error'; failure: LlmFailure }
}

FinishReason = FinishReasonMap[keyof FinishReasonMap]TokenUsage(逐调用计量,含不相交的缓存字段)详见下文

GenerateOptions.tools 携带 ToolSchema——工具的 JSON Schema 描述,发送给模型。它声明在 dsh-llm(而非 dsh-tools)中,正是因为它是循环每一步组装请求的一部分:

/**
 * JSON-schema description of a tool, as sent to the model.
 *
 * Declared here (not in dsh-tools) because it is part of {@link GenerateOptions};
 * dsh-tools' ToolDefinition and dsh-system-prompt's PromptAssembly both import
 * it from this package.
 */
interface ToolSchema {
  name: string
  description: string
  /** JSON Schema object for the arguments. */
  parameters: Record<string, unknown>
}

面向模型的 ToolSchema 是协议格式;产出它的已注册 ToolDefinitionschema + execute)在 tools.md 中。

请求信封:LlmCallConfig 与记录的 header

循环从已记录状态构建每个请求。EpochHeader 通过完整的 request/header 快照记录调用配置、适配器默认值来源、渲染后的提示词以及权威返回工具顺序(由 toolOrder 配置;未配置时按字典序)。结合派生历史,请求便可由会话日志重建。见 session.md可重建性 Agent Noteagent 决策记录)

agent/request 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置及其来源。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 llm/stream 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。

在协议格式上,循环构建的请求先读取 system 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 user/message,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。

FIXME(call-config-shape):重新审视其余哪些字段出于缓存目的确实属于 epoch 层级(model 和模型持有的推理强度已明确属于;采样标量目前出于谨慎保留在此)。

/**
 * Provider, model, reasoning effort, and sampling scalars of one conversation's
 * requests. Every field maps 1:1 onto the same-named `GenerateOptions` field;
 * the loop builds requests from the logged header rather than accepting these
 * per call.
 */
interface LlmCallConfig {
  provider: string
  model: string
  reasoningEffort?: ReasoningEffortId
  temperature?: number
  maxTokens?: number
  stop?: string[]
}
/**
 * Effective config fields supplied by exact-model adapter resolution rather
 * than by the caller's request proposal.
 */
interface LlmCallConfigAdapterDefaults {
  reasoningEffort?: true
  maxTokens?: true
}

seam

LlmAdapter 是提供方 seam:创建子类、实现 stream(),再用 ctx.llm.registerAdapter(providers, adapter) 注册一个适配器实例。GenerateOptions.provider 选择已注册适配器;GenerateOptions.model 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 providerRetryPolicy() 会按路由捕获并填入 normal 默认值,providerInfo() 与异步 listModels() 方法则为 LlmService.listProviders() / listModels() 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 resolveModel() 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 defaultMaxTokens、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。LlmService.resolveModelInfo() 会校验聚合结果并返回分离值。在最终适配器边界,resolveCallConfig() 仅在 maxTokens 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 prepareCall(),使模型解析、请求头持久记录和分派全程使用同一项注册,保留来自同一次查询的分离上下文元数据,并报告适配器填入的配置字段。适配器查找发生在 llm/stream waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。AgentLoop 在外层 waterfall 返回流句柄时观察到一次请求尝试;这个有限边界不能证明惰性终端适配器已构造完成或开始提供方 I/O。block-start / block-endindex 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(ctx.llm.stream())与 llm/stream waterfall 见 architecture.md § 内容块与流式传输

/** One model call whose config and adapter registration were resolved together. */
interface PreparedLlmCall {
  /** Detached, deep-frozen config with any adapter-owned default materialized. */
  readonly config: LlmCallConfig
  /** Detached context metadata resolved with the registration-bound call. */
  readonly context?: LlmModelContext
  /** Config fields materialized by the captured adapter rather than proposed by the caller. */
  readonly adapterDefaults: LlmCallConfigAdapterDefaults
  /**
   * Dispatch this call once through the registration captured during
   * preparation. The request's call-config fields must match {@link config};
   * reuse or mismatch fails with `INVALID_PREPARED_CALL`.
   * @param options - fully assembled request carrying the prepared config.
   * @returns the chunk stream, including the `llm/stream` waterfall.
   */
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>
}
/**
 * Provider-wire adapter for the harness message and stream vocabulary. Register implementations
 * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include
 * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch
 * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals.
 */
declare abstract class LlmAdapter {
  /**
   * Describe one provider route owned by this adapter.
   * @param provider - a route passed to `registerAdapter()` for this instance.
   * @returns detached display metadata whose id must equal `provider`.
   */
  providerInfo(provider: string): LlmProviderInfo;
  /**
   * Return the provider-owned retry policy captured with this route.
   * @param _provider - a route passed to `registerAdapter()` for this instance.
   * @returns a resolved policy, or `undefined` to use the normal defaults.
   */
  providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;
  /**
   * List models this adapter can currently advertise for one owned provider.
   * The result is advisory: an adapter may accept unlisted model ids, and
   * consumers must not turn absence into request rejection.
   * @param _provider - one provider route owned by this adapter.
   * @returns discoverable models in adapter-preferred order.
   */
  listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
  /**
   * Resolve all metadata available for one exact model. This query is
   * independent of the advisory catalog and does not validate request routing.
   * @param provider - one provider route owned by this adapter.
   * @param model - exact model id passed to {@link GenerateOptions.model}.
   * @param _signal - cancellation for this exact-model lookup; asynchronous
   *   implementations must settle promptly after it aborts.
   * @returns provider/model identity plus any context, call-default, and reasoning metadata.
   */
  resolveModel(
    provider: string,
    model: string,
    _signal?: AbortSignal,
  ): Promise<LlmResolvedModelInfo>;
  /**
   * 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>;
}

ContentBlockType(带 index 关联的块所携带的键集合)从上文的 ContentBlockMap 派生。

Cordis surface

Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — this section is byte-identical in both language sides of the page. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx surface lives in cordis-api/inherited.md.

ctx.llmLlmService

The abstract llm service: an adapter registry plus a streaming model-call surface, interceptable via the llm/stream waterfall.

/**
 * 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

/**
 * Describe provider routes with a registered adapter.
 * @returns detached provider metadata in registration order.
 */
listProviders(): LlmProviderInfo[]

/**
 * Declare provider routes an adapter plugin can activate through
 * configuration. Registration is all-or-nothing: an empty list, invalid
 * entry, or a provider already declared by any registration throws
 * `LlmError` without registering the rest. Disposed with the fiber.
 * @param entries - every configurable provider this plugin owns.
 * @returns the disposer that withdraws all of them.
 */
registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void

/**
 * List every declared configurable provider, registered or dormant.
 * @returns detached directory entries in declaration order.
 */
listConfigurableProviders(): LlmConfigurableProvider[]

/**
 * Resolve the retry policy captured when one provider route was registered.
 * @param provider - registered provider route to inspect.
 * @returns the provider-owned policy, with normal defaults already resolved.
 */
providerRetryPolicy(provider: string): ResolvedRetryPolicy

/**
 * Discover models advertised by one registered provider. Catalog membership
 * is advisory and never changes routing or request validation.
 * @param provider - registered provider route to inspect.
 * @returns detached model metadata in adapter-preferred order.
 */
async listModels(provider: string): Promise<LlmModelInfo[]>

/**
 * Resolve and validate all metadata from the adapter that owns one exact
 * route. The result is detached from adapter-owned objects; catalog
 * membership remains advisory and does not control request routing.
 * @param provider - registered provider route to inspect.
 * @param model - exact model id passed to the adapter.
 * @param signal - optional cancellation for adapter-owned asynchronous lookup.
 * @returns exact model identity plus available context and reasoning metadata.
 */
async resolveModelInfo( provider: string, model: string, signal?: AbortSignal, ): Promise<LlmResolvedModelInfo>

/**
 * Validate a conversation call config against its exact model capability and
 * materialize adapter-configured defaults. Unsupported explicit efforts
 * reject before provider I/O; no clamping or aliasing is performed. This
 * standalone query does not bind a later dispatch; use {@link prepareCall}
 * when logging and streaming must share one adapter registration.
 * @param config - provider/model route and optional request controls.
 * @param signal - optional cancellation for adapter-owned capability lookup.
 * @returns a detached config only when a default must be materialized.
 */
async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig>

/**
 * Resolve one call under its current adapter registration. The returned
 * one-shot handle keeps that registration across header logging and dispatch,
 * so HMR cannot combine one adapter's capability result with another adapter.
 * @param config - provider/model route and optional request controls.
 * @param signal - optional cancellation for adapter-owned capability lookup.
 * @returns a prepared config and its registration-bound stream entry point.
 */
async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall>

/**
 * Stream one model call as raw chunks (token-level deltas). Throws
 * `LlmError` with code `NO_ADAPTER` if no adapter is registered for
 * `options.provider`. Replay state is retained only when the same adapter
 * instance owns its historical provider and the target provider. Final
 * adapter selection remains fixed through asynchronous exact-model resolution
 * and dispatch. Selection, dispatch, and iteration failures retain their
 * original Error identity and are tagged in a call-local scope for narrow
 * agent-loop request recovery; middleware and nested-call failures remain
 * untagged for the outer call.
 * @param options - the full request; `options.provider` selects the adapter.
 * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
 */
stream(options: GenerateOptions): AsyncIterable<StreamChunk>

Source: packages/llm/llm/src/index.ts:232

llm/* events

llm/adapters-updated — emit

The provider topology changed: an adapter registered or unregistered routes, or the configurable-provider directory gained or lost entries. This is a payload-free registry notification fired at each commit point (including registration disposal); consumers re-read listProviders(), listModels(), or listConfigurableProviders() for the new state. Observer failures are contained and cannot veto the registry mutation.

/**
 * The provider topology changed: an adapter registered or unregistered
 * routes, or the configurable-provider directory gained or lost entries.
 * This is a payload-free registry notification fired at each commit point
 * (including registration disposal); consumers re-read `listProviders()`,
 * `listModels()`, or `listConfigurableProviders()` for the new state.
 * Observer failures are contained and cannot veto the registry mutation.
 * @mode emit
 */
'llm/adapters-updated'(): void

Source: packages/llm/llm/src/index.ts:71

llm/stream — waterfall

Waterfall around every streaming model call (retry, replay, routing). Bound to the LlmService; call next() to reach the resolved adapter's stream, or yield your own chunks to short-circuit.

/**
 * Waterfall around every streaming model call (retry, replay, routing).
 * Bound to the {@link LlmService}; call `next()` to reach the resolved
 * adapter's stream, or yield your own chunks to short-circuit.
 * @param options - the full request. A LOOP-built request carries the
 *   process-local {@link markAgentLoopRequest} identity and arrives deep-frozen
 *   (mutation throws): its content is a pure function of the session log (the
 *   reconstructability Agent Note), so listeners read it, never rewrite it.
 *   Hand-built calls do not carry that marker; their messages already obey
 *   the immutable creation contract.
 * @mode waterfall
 */
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>

Source: packages/llm/llm/src/index.ts:60