数据截至 (上游 commit 1f8f3e285bbc)
03 · 模型层与 token 截断
这章讲两个基础设施件:
getModel(src/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):
openai用createOpenAI,baseURL可被OPENAI_ENDPOINT覆盖——这就是"接本地/第三方 OpenAI 兼容服务"的开关。o3MiniModel传reasoningEffort: '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_KEY 和 OPENAI_KEY,永远走 R1,o3-mini 被无声忽略——README 说的"检测到 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] ?? '';
默认 contextSize 读 CONTEXT_SIZE,兜底 128000(:67);encoder 用 o200k_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。