跳到主要内容

03 · 模型层与 token 截断

这章讲两个基础设施件:getModelsrc/ai/providers.ts:48)怎么在多个 LLM 提供商间切换,和 trimPrompt:65)怎么把过长的正文安全地截到上下文窗口内。

3.10 getModel — 一个函数适配三类模型

要解决的小问题: 项目想让用户在 OpenAI o3-mini、DeepSeek-R1(走 Fireworks)、或任意 OpenAI 兼容自定义模型(本地 LM Studio、OpenRouter、Gemini…)之间随便选,但业务代码不该关心用的是谁。

思路: 启动时按环境变量有条件地构造几个模型实例,getModel() 按固定优先级挑一个返回。所有业务代码只调 getModel(),对底层无感。

优先级(src/ai/providers.ts:48-59):

customModel (CUSTOM_MODEL 存在)
└─ 否 → deepSeekR1Model (FIREWORKS_KEY 存在) ?? o3MiniModel (OPENAI_KEY 存在)
└─ 都没有 → throw 'No model found'

三个实例的构造(:13-46):

  • openaicreateOpenAIbaseURL 可被 OPENAI_ENDPOINT 覆盖——这就是"接本地/第三方 OpenAI 兼容服务"的开关。
  • o3MiniModelreasoningEffort: 'medium'structuredOutputs: true
  • deepSeekR1Model 最有意思:R1 是推理模型,会把思考过程包在 <think>...</think> 里。这里用 wrapLanguageModel + extractReasoningMiddleware({ tagName: 'think' }) 把思考标签自动剥离,业务侧只拿到最终答案:
const deepSeekR1Model = fireworks
? wrapLanguageModel({
model: fireworks('accounts/fireworks/models/deepseek-r1') as LanguageModelV1,
middleware: extractReasoningMiddleware({ tagName: 'think' }),
})
: undefined;

坑: 优先级是硬编码的。若你同时配了 FIREWORKS_KEYOPENAI_KEY,永远走 R1,o3-mini 被无声忽略——READ­ME 说的"检测到 key 就自动切 R1"就是这个 ?? 的行为。想强制某个模型,用 CUSTOM_MODEL(它优先级最高)。

3.11 trimPrompt — 按 token 上限递归裁剪

要解决的小问题: 抓来的网页正文可能几万 token,直接塞进 prompt 会超模型上下文窗口。得截短,但"截多少字符"和"多少 token"不是线性对应的,不能瞎砍。

思路: 三步——用 tiktoken 真数 token;若超了,用一个经验公式"超出 token 数 × 3 字符"估算要砍掉多少字符,得到目标 chunkSize;再用递归字符分割器在自然边界(段落/句子/词)切出第一块,递归直到进上下文。

原理演示(简化,非源码)

// 示意,非源码:trimPrompt 的核心思路
function trim(text, limit) {
const tokens = countTokens(text); // tiktoken 数 token
if (tokens <= limit) return text; // 没超,原样返回

const overflow = tokens - limit;
const targetChars = text.length - overflow * 3; // 经验:1 token≈3 字符
const firstChunk = splitOnNaturalBoundaries(text, targetChars)[0]; // 段落/句子边界切
return trim(firstChunk, limit); // 递归收敛
}

重点看 overflow * 3:这是个"平均 3 字符/token"的粗估,故意保守(宁可多砍),砍完再数一遍 token,不够短就再来一轮。

真实实现

对照 src/ai/providers.ts:65-98

const length = encoder.encode(prompt).length;
if (length <= contextSize) return prompt;

const overflowTokens = length - contextSize;
const chunkSize = prompt.length - overflowTokens * 3; // 估算目标字符数
if (chunkSize < MinChunkSize) return prompt.slice(0, MinChunkSize); // 下限保护

const splitter = new RecursiveCharacterTextSplitter({ chunkSize, chunkOverlap: 0 });
const trimmedPrompt = splitter.splitText(prompt)[0] ?? '';

默认 contextSizeCONTEXT_SIZE,兜底 128000(:67);encodero200k_base 编码(:62)。

两处防死循环的兜底(正是"读真源码才看得到"的细节):

  • chunkSize < MinChunkSize(140)时,不走分割器,直接硬切前 140 字符(:81-83)——避免估算把 chunkSize 算成负数或极小值。
  • 分割器有时切出来跟原文一样长(token 与字符的错位),此时 trimmedPrompt.length === prompt.length改用硬切 prompt.slice(0, chunkSize) 再递归(:91-94),否则会无限递归。

RecursiveCharacterTextSplitter 怎么在"自然边界"切

分割器(src/ai/text-splitter.ts:87-143,移植自 LangChain)按一串优先级分隔符尝试切:

separators: string[] = ['\n\n', '\n', '.', ',', '>', '<', ' ', ''];

直觉:先尽量在段落\n\n)处断,段落还太大就退到换行句号逗号……最后退到单字符'')。这样截断尽量落在语义边界,不会把一个词劈两半。> < 在列表里是因为抓来的是 markdown/HTML,尖括号也是有意义的断点。

下一章:把前三章的巧妙处提炼成"可借鉴技术",谈边界与横向对比 → 04-deep-dive-and-map.md