@context-chef/ai-sdk-middleware
v3.1.1
Published
AI SDK middleware for context-chef. Transparent history compression, tool result truncation, and token budget management.
Maintainers
Readme
@context-chef/ai-sdk-middleware
基于 context-chef 的 Vercel AI SDK 中间件。透明的历史压缩、工具结果截断和 token 预算管理 — 无需修改任何代码。
📖 文档站: https://myprototypewhat.github.io/context-chef/zh/packages/ai-sdk-middleware · English

安装
npm install @context-chef/ai-sdk-middleware ai快速开始
import { withContextChef } from '@context-chef/ai-sdk-middleware';
import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
const model = withContextChef(openai('gpt-4o'), {
contextWindow: 128_000,
compress: { model: openai('gpt-4o-mini') },
truncate: { threshold: 5000, headChars: 500, tailChars: 1000 },
});
// 下面的代码完全不变 — 兼容 generateText、streamText 和 ToolLoopAgent
const result = await generateText({
model,
messages: conversationHistory,
tools: myTools,
});就这样。历史压缩、工具结果截断和 token 预算追踪在后台自动完成。
功能
历史压缩
当对话超出 token 预算时,中间件会压缩旧消息以腾出空间。两种模式:
不配置压缩模型(默认)— 旧消息被丢弃,仅保留近期消息:
const model = withContextChef(openai('gpt-4o'), {
contextWindow: 128_000,
});配置压缩模型 — 旧消息由便宜模型生成摘要后替换:
const model = withContextChef(openai('gpt-4o'), {
contextWindow: 128_000,
compress: {
model: openai('gpt-4o-mini'), // 用于摘要的便宜模型
preserveRatio: 0.8, // 保留 80% 的上下文给近期消息
},
});In-flight 与 durable 的区别。 中间件的
compress是 in-flight 的:它只重写每次发出去的请求,不会改动你的消息存储。所以对于持续超预算的对话(长聊天,或长的多步 loop),摘要每次调用后即被丢弃、历史随即回胀 —— 压缩实际上只会隔次触发,payload 还会持续增长。一次性尖峰无所谓;若是持续场景,请通过onCompress持久化摘要,或用compactModelMessages压缩你自己的存储(推荐)。若compress反复触发却没配onCompress,中间件会打印一次告警。
溢出策略(Overflow)
compress 是用一组选项去描述一套压缩策略;overflow.strategy 则本身就是那套策略。传入 @context-chef/core 里的策略 —— summarize()、anchored()、reset(),以及 chain() / background() 组合,或你自己实现的对象 —— 它会整体取代 compress 所描述的策略:
import { chain, InMemoryBackend, reset, Store, summarize } from '@context-chef/core';
const archive = new Store(new InMemoryBackend()).namespace('archive');
const model = withContextChef(openai('gpt-4o'), {
contextWindow: 128_000,
overflow: {
strategy: chain(summarize({ compressionModel }), reset()),
archive: { store: async (serialized) => (await archive.put(serialized)).uri },
},
});- 只配策略也会开启预算检查 —— 不需要再写
compress块。但一旦配置了策略,contextWindow就是必填的,否则createMiddleware/withContextChef会抛错:预算检查没有可比较的基准。 overflow.strategy覆盖compress的调参项(preserveRatio、minShrinkRatio、toolResultStubThreshold,以及compress.model)—— 同一件事有两套描述必然打架。summarize()自带compressionModel回调,签名是(messages: Message[]) => Promise<string>。- runner 侧的配置继续生效,与用哪套策略无关:
contextWindow、tokenizer、compress.triggerRatio、compress.usagePreference、onCompress、onBeforeCompress、maxSessions、logger。 overflow.archive只接受显式的{ store }形式。'vfs'简写会替换成 ContextChef 自己的 Offloader,而中间件没有它。被驱逐的片段以JSON.stringify({ version: 1, messages })传入,返回的 URI 会被摘要引用。
这里不提供
overflow.handoff和tools。 handoff 提示走的是 tail 通道,只有ContextChef.compile()才有;而本包只重写 prompt,从不产出工具定义,所以 tools 模式在这里没有作用对象。需要这两者时,请直接使用@context-chef/core,在ContextChef上注册getContextToolDefinition()。
工具结果截断
大体积工具输出(终端日志、API 响应)会被自动截断,同时保留头部和尾部:
const model = withContextChef(openai('gpt-4o'), {
contextWindow: 128_000,
truncate: {
threshold: 5000, // 超过 5000 字符时截断
headChars: 500, // 保留开头 500 字符
tailChars: 1000, // 保留结尾 1000 字符
},
});可选地把原始内容持久化到 context store,方便后续被工具、审计流水线或回放层取回:
import { FileSystemBackend } from '@context-chef/core';
const model = withContextChef(openai('gpt-4o'), {
contextWindow: 128_000,
truncate: {
threshold: 5000,
headChars: 500,
tailChars: 1000,
store: new FileSystemBackend('.context'), // 也可以是 InMemoryBackend 或自定义 StorageBackend
},
});store 接受一个 StorageBackend(InMemoryBackend、FileSystemBackend 或你自己的实现),也接受一个已构造好的 Store —— 把同一个 Store 同时交给 overflow.archive,一个后端就能同时服务截断和归档。无论哪种方式,截断后的内容都会带上 context://vfs/ URI。
truncate.storage在 4.2 中已废弃(5.0 移除)—— 请改传store。旧的VFSStorageAdapter仍然可用:它会被Store.fromVfsAdapter包装,用其单一扁平键空间同时服务vfs与archive两个 namespace。两者同时配置时,store胜出。
当后端暴露物理路径(FileSystemBackend 与旧的 FileSystemAdapter 都通过 getPhysicalPath 支持),截断 marker 会把该路径作为首选的取回句柄输出 —— 模型用现成的 file-read 工具直接读取即可,不必另写一个识别自定义 URI 的工具。不映射到文件系统的后端(DB、内存)则不实现 getPhysicalPath,marker 退化为单独的 context://vfs/ URI。
通过 perTool 做按工具覆写 —— 字符串条目完全保留该工具(同时跳过 VFS 写入),对象条目则只针对该工具覆盖 threshold / headChars / tailChars:
const model = withContextChef(openai('gpt-4o'), {
contextWindow: 128_000,
truncate: {
threshold: 5000,
tailChars: 1000,
perTool: [
'read_file', // 永不截断;也不写入 VFS
{ name: 'fetch_logs', threshold: 50_000 }, // 提高阈值
{ name: 'big_query', tailChars: 5000 }, // 保留更多尾部
],
},
});未列出的工具继续使用顶层默认值。查找键是 tool-result.toolName,过滤粒度是单个 tool-result part —— 因此同一条 tool 消息可以混合保留与截断的 part。(TanStack 中间件的同名选项粒度是单条消息,因为 TanStack 的一条 tool 消息对应一次 tool 调用。)不支持通配符,store / storage 也无法按工具覆写。perTool 只控制 truncate 这一步 —— 保留下来的消息仍可能被 compact 整条删除(若 compact.toolCalls 命中)、被 compress 在超出 token 预算时摘要,或被 transformContext 改写。
Token 预算追踪
中间件自动从 generateText 和 streamText 响应中提取 token 用量,并回传给压缩引擎。无需手动调用 reportTokenUsage()。
Compact(机械裁剪)
零 LLM 成本的消息裁剪,基于 AI SDK 的 pruneMessages — 移除 reasoning、工具调用和空消息:
const model = withContextChef(openai('gpt-4o'), {
contextWindow: 128_000,
compact: {
reasoning: 'all', // 移除所有 reasoning
toolCalls: 'before-last-message', // 仅保留最后一条消息中的工具调用
},
});也支持按工具名称精细控制:
compact: {
toolCalls: [
{ type: 'before-last-message', tools: ['search', 'calculator'] },
],
}API
withContextChef(model, options)
用 context-chef 中间件包装 AI SDK 语言模型。
import { withContextChef } from '@context-chef/ai-sdk-middleware';
const wrappedModel = withContextChef(model, options);参数:
| 选项 | 类型 | 必填 | 说明 |
|---|---|---|---|
| contextWindow | number | 是 | 模型的上下文窗口大小(token 数) |
| compress | CompressOptions | 否 | 启用基于 LLM 的压缩 |
| compress.model | LanguageModelV3 | 是(如启用 compress) | 用于摘要的便宜模型 |
| compress.preserveRatio | number | 否 | 保留上下文的比例(默认:0.8) |
| compress.triggerRatio | number | 否 | 触发压缩的 contextWindow 比例(0–1]。默认 0.7("pre-rot" — 模型质量在触及硬窗口上限之前就已下降,所以提前压缩)。设为 1 可恢复 4.0 之前"到窗口才触发"的行为。配置了 tokenizer 时,preserveRatio 作用于 contextWindow * triggerRatio 这个有效预算,而不是原始窗口。 |
| compress.minShrinkRatio | number | 否 | 压缩结果必须把被压缩片段的字符长度至少缩小该比例(0–1,默认 0.5)。未达标的摘要按压缩失败处理:历史保持不变,并计入熔断器。设为 0 可关闭该检查。 |
| compress.toolResultStubThreshold | number | 否 | 在把待摘要历史送给 compression model 之前,将超过该字符数的 tool-result 内容替换为一行元信息桩([Tool name returned N chars; omitted before summarization])。近期保留的 tool-result 不动。默认:undefined(关闭)。 |
| compress.usagePreference | 'max' \| 'feedFirst' \| 'tokenizerFirst' | 否 | 当 tokenizer 与 AI SDK 上报的 usage 同时存在时,决定触发判断使用哪个 token 来源。默认 'max'(最保守 — Math.max(tokenizer, fed))。'feedFirst' 信任 API 真值,避免 tokenizer 高估导致的提前压缩;'tokenizerFirst' 完全忽略上报的 usage。'tokenizerFirst' 需要 tokenizer,缺失时构造期会被消毒为 'max' 并打印控制台警告。 |
| truncate | TruncateOptions | 否 | 启用工具结果截断 |
| truncate.threshold | number | 是(如启用 truncate) | 触发截断的字符数 |
| truncate.headChars | number | 否 | 保留开头的字符数(默认:0) |
| truncate.tailChars | number | 否 | 保留结尾的字符数(默认:1000) |
| truncate.store | StorageBackend \| Store | 否 | 承载 vfs namespace 的 context store —— InMemoryBackend、FileSystemBackend、自定义实现,或与 overflow.archive 共用的 Store。截断后的内容带 context://vfs/ URI。优先级高于 storage。 |
| truncate.storage | VFSStorageAdapter | 否 | 4.2 起废弃 → truncate.store。 旧的存储适配器,会被 Store.fromVfsAdapter 包装。 |
| truncate.perTool | Array<string \| { name; threshold?; headChars?; tailChars? }> | 否 | 按工具覆写。字符串 = 保留(同时跳过存储);对象 = 为该工具覆写参数。重复名称时后者胜出。 |
| compact | CompactConfig | 否 | 机械消息裁剪(reasoning、工具调用)。委托给 AI SDK 的 pruneMessages |
| tokenizer | (msgs) => number | 否 | 自定义分词器用于精确计数 |
| onCompress | (summary, count, details) => void | 否 | 压缩完成后的回调。details.compressedMessages 是被摘要替换掉的 AI SDK 格式(LanguageModelV3Prompt)切片,可用于在自有存储中持久化摘要边界。 |
| logger | ChefLogger | 否 | 降级警告的输出目标(存储写入失败、缺少 usage 数据、配置异常等),默认 console。转发给底层 Janitor 和 Offloader。 |
| overflow | { strategy?, archive? } | 否 | 溢出轴 —— 见溢出策略(Overflow)。 |
| overflow.strategy | OverflowStrategy | 否 | 来自 @context-chef/core 的 summarize() / anchored() / reset() / chain() / background(),或自定义实现。会覆盖 compress 的调参项(含 compress.model);runner 侧配置继续生效。配置后 contextWindow 变为必填。 |
| overflow.archive | CompressionArchiveConfig | 否 | { store: (serialized, { messageCount }) => uri } —— 让被驱逐的片段仍可通过摘要引用的 URI 取回。'vfs' 简写仅 core 支持。 |
| clear | ClearTarget[] | 否 | 占位符式的 tool-result 清除。被清除的工具结果变为 '[Old tool result content cleared]'——消息结构保持完整,与删除内容的 compact 不同。在压缩之后执行。当工具结果被清除时,自动注入一条系统消息以避免模型将占位符读作错误。仅 'tool-result' 目标生效;'thinking' 目标为空操作(会记录一条警告)——请用 compact: { reasoning: ... } 删除 reasoning。ClearTarget 从 @context-chef/core 导出。 |
返回值: LanguageModelV3 — 包装后的模型,可在任何使用原模型的地方直接替换。
createMiddleware(options)
创建原始 LanguageModelMiddleware,可通过 wrapLanguageModel 自行应用:
import { createMiddleware } from '@context-chef/ai-sdk-middleware';
import { wrapLanguageModel } from 'ai';
const middleware = createMiddleware({ contextWindow: 128_000 });
const model = wrapLanguageModel({ model: openai('gpt-4o'), middleware });fromAISDK(prompt) / toAISDK(messages)
AI SDK LanguageModelV3Prompt 与 context-chef Message[] IR 之间的底层转换器。适用于直接使用 context-chef 模块处理 AI SDK 消息格式的场景。
import { fromAISDK, toAISDK } from '@context-chef/ai-sdk-middleware';
const irMessages = fromAISDK(aiSdkPrompt);
// ... 用 context-chef 模块处理 ...
const aiSdkPrompt = toAISDK(irMessages);summarizeMessages(prompt, model, options?)
将一段 AI SDK prompt 切片摘要为单个字符串,使用与在途压缩相同的流水线。它会先 fromAISDK、丢弃 system 消息,再以内置的角色扁平化适配器调用 core 的 summarizeHistory —— 因此你无需自行扁平化 tool 角色。返回提取出的摘要文本;空 prompt 不调用模型直接返回 '',模型调用失败时抛出异常。
适用于持久化压缩:由宿主自己拥有对话存储并自行持久化压缩结果,而非依赖中间件的透明压缩。
import { summarizeMessages } from '@context-chef/ai-sdk-middleware';
const summary = await summarizeMessages(promptSlice, model, {
toolResultStubThreshold: 5000,
});
// 自行持久化 { summary, boundary };之后回放 [summary] + [recent]。SummarizeMessagesOptions 是 core SummarizeHistoryOptions 的结构别名(customCompressionInstructions、toolResultStubThreshold)。如需「续接对话」的框架文本,用 @context-chef/core 的 getCompactSummaryWrapper 包裹返回值。
协调注意: 若以此方式驱动摘要,请不要在同一中间件路径上对同一对话再配置
compress(带model)—— 那会重复压缩(调用时一次、持久化时再一次)。仅做通知的onCompress,以及truncate、clear、dynamicState可安全并用。
compactModelMessages(messages, model, options)
一站式 durable 压缩 —— 当你拥有消息存储时(长 agent loop,或超预算的聊天),让长对话保持精简的推荐方式。它工作在 ModelMessage 层级 —— 即 generateText 和 prepareStep 实际交给你的 ModelMessage[] 类型。它在 turn 边界切分历史、对旧切片摘要,返回一份可直接持久化的新 ModelMessage[] —— [...system, <摘要>, ...最近若干轮]。可在你自己的 loop 里运行(拥有存储并把结果写回),或放进 ToolLoopAgent:
import { compactModelMessages } from '@context-chef/ai-sdk-middleware';
const agent = new ToolLoopAgent({
model,
tools,
prepareStep: async ({ messages, model }) => ({
messages: await compactModelMessages(messages, model, { keepRecentTurns: 4 }),
}),
});或在模型调用之间,当你持有 messages 时:
const next = await compactModelMessages(messages, summarizerModel, {
keepRecentTurns: 4, // 逐字保留最近 4 个原子轮次
toolResultStubThreshold: 5000,
});
if (next !== messages) await save(next); // no-op 时跳过持久化model是ai的LanguageModel(string id | V3 | V2)—— 正是prepareStep/generateText交给你的类型。- 切点只落在 turn 边界(assistant 及其 tool 结果作为整体),所以不会 orphan tool 结果、也不会切进多 block 的 assistant 消息。
- system 消息逐字保留、永不被摘要。
- 当没有足够旧的内容可压(轮数不超过
keepRecentTurns)、或摘要器无输出时,原样返回同一个messages引用 —— 因此可通过next !== messages在 no-op 时跳过持久化。可无条件调用;仅当模型调用抛错时才抛错。 - 接受与
summarizeMessages相同的SummarizeMessagesOptions(customCompressionInstructions、toolResultStubThreshold)。
对同一对话用这个或 in-flight
compress,二选一(同时用会重复压缩)。
keepRecentTurns数的是消息级 turn,不是ToolLoopAgent的 step。 一个 turn 是一条 user/assistant 消息,或一条带 tool-calls 的 assistant 加它全部 tool 结果(绑成一体)。一次用工具的 step 往往是 2–3 个 turn,所以请按你最坏单 step 的消息数来设keepRecentTurns—— 工具密集的 agent loop 要比纯聊天设得更大。摘要是按user消息插入的,所以当保留尾部也以 user turn 开头时,结果可能出现连续两条user消息 —— 这是合法的ModelMessage[],AI SDK 的 provider 层会归一化(Anthropic 合并同角色、OpenAI 直接接受)。
内部实现:
compactModelMessages、planCompactionModelMessages和summarizeModelMessages是@context-chef/core中 provider 无关引擎的 AI-SDK 薄壳 —— 它们把ModelMessage[]转成 core 的 IR 再转回来。若你直接对接某个 provider(不经 AI SDK),请改用 core 的planCompaction/compactHistory。
全压(Claude Code 式)
传 keepRecentTurns: 0,把整段对话压成一条摘要 —— 结果只有 [...system, <摘要>],没有逐字尾巴。这是最易持久化的模式:因为没有保留的尾巴,就不存在跟你存储自身单元边界(一个多 step 的 agent turn、一条跨多个 step 的 UI 消息等)对齐的问题 —— 回写就是「用这两条结果替换存储」。每一轮都塌回 [system, summary]。
代价是没有任何逐字近况留存 —— 模型完全从一份有损摘要继续。所以摘要质量就是一切;用 customCompressionInstructions 把它导向结构化交接:
const next = await compactModelMessages(messages, summarizerModel, {
keepRecentTurns: 0, // 全压 —— 把一切塌进摘要
customCompressionInstructions: [
'把摘要写成可续作任务的交接文档:',
'- 已完成的工作与当前状态',
'- 做了哪些决策、为什么',
'- 涉及的文件 / 资源',
'- 下一步要做的确切动作',
].join('\n'),
});
// 此时 `next` 就是 [system, summary] —— 持久化极简,无需任何边界记账。想保住「进行中那一轮」的逐字上下文时(无人值守的 loop 做事中更安全),用小的 keepRecentTurns(如 2–4);想最大化收缩、要一个干净无边界的存储时,用 0。
planCompactionModelMessages(messages, options)
compactModelMessages 背后的同步切分函数,适用于只要边界、不想立刻摘要的场景(持久化你自己的标记,或用别的摘要器)。返回 { system, toSummarize, toKeep }(均为 ModelMessage[]),按 turn 边界切分:
import { planCompactionModelMessages, summarizeModelMessages } from '@context-chef/ai-sdk-middleware';
import { Prompts } from '@context-chef/core';
const { system, toSummarize, toKeep } = planCompactionModelMessages(messages, { keepRecentTurns: 4 });
if (toSummarize.length > 0) {
const summary = await summarizeModelMessages(toSummarize, model);
messages = [
...system,
{ role: 'user', content: [{ type: 'text', text: Prompts.getCompactSummaryWrapper(summary) }] },
...toKeep,
];
}summarizeModelMessages(messages, model, options?)
summarizeMessages 的 ModelMessage 层级兄弟函数:用同一流水线(角色扁平化 + core summarizeHistory)把一段 ModelMessage[] 切片摘要为单个字符串。system 消息会被丢弃。空切片不调用模型直接返回 '',模型调用失败时抛出异常。当你想自行驱动摘要、而非用一站式的 compactModelMessages 时,配合 planCompactionModelMessages 使用。
compactHistory(prompt, model, options)
已废弃。
compactHistory/planCompaction收发的是LanguageModelV3Prompt—— provider 协议层级,没人会持久化这个类型。请改用compactModelMessages/planCompactionModelMessages。仍然导出且可用;下个大版本移除。
compactModelMessages 的 V3-prompt 变体。它在 turn 边界切分历史、对旧切片摘要,返回一份可直接持久化的新 prompt —— [...system, <摘要>, ...最近若干轮]:
import { compactHistory } from '@context-chef/ai-sdk-middleware';
// 在模型调用之间,当你持有 `messages` 时:
messages = await compactHistory(messages, summarizerModel, {
keepRecentTurns: 4, // 逐字保留最近 4 个原子轮次
toolResultStubThreshold: 5000,
});
// 持久化结果 —— 历史真正变小并保持精简。- 切点只落在 turn 边界(assistant 及其 tool 结果作为整体),所以不会 orphan tool 结果、也不会切进多 block 的 assistant 消息。
- system 消息逐字保留、永不被摘要。
- 当没有足够旧的内容可压(轮数不超过
keepRecentTurns)、或摘要器无输出时,原样返回 prompt —— 可无条件调用。仅当模型调用抛错时才抛错。 - 接受与
summarizeMessages相同的SummarizeMessagesOptions(customCompressionInstructions、toolResultStubThreshold)。
planCompaction(prompt, options)
已废弃。 请改用
planCompactionModelMessages。 这个 V3-prompt 变体是 provider 协议层级 —— 一个你永远不会持久化的类型。仍然导出且可用;下个大版本移除。
compactHistory 背后的同步切分函数,适用于只要边界、不想立刻摘要的场景(持久化你自己的标记,或用别的摘要器)。返回 { system, toSummarize, toKeep }(均为 LanguageModelV3Prompt),按 turn 边界切分:
import { planCompaction, summarizeMessages } from '@context-chef/ai-sdk-middleware';
import { Prompts } from '@context-chef/core';
const { system, toSummarize, toKeep } = planCompaction(messages, { keepRecentTurns: 4 });
if (toSummarize.length > 0) {
const summary = await summarizeMessages(toSummarize, model);
messages = [
...system,
{ role: 'user', content: [{ type: 'text', text: Prompts.getCompactSummaryWrapper(summary) }] },
...toKeep,
];
}工作原理
generateText / streamText / ToolLoopAgent ({ model: wrappedModel, messages })
|
v
transformParams(LLM 调用前)
1. 截断大体积工具结果(如已配置)
- 可选持久化原始内容到存储适配器
2. AI SDK 消息 -> context-chef IR
3. 运行 Janitor 压缩(如超出 token 预算)
4. 转换回 AI SDK 消息
|
v
LLM 调用正常执行
|
v
wrapGenerate / wrapStream(LLM 调用后)
5. 从响应中提取 token 用量
6. 回传给 Janitor 用于下次调用的预算检查
|
v
结果原样返回中间件是有状态的 — 它跨调用追踪 token 用量以判断何时需要压缩。每个对话/会话创建一个包装模型实例。
需要更多控制?
中间件覆盖了最常见的场景:透明的压缩和截断。如需动态状态注入、工具命名空间、记忆、快照/恢复,或统一的 context 工具(tools: 'unified')与 handoff 预算(overflow.handoff)等高级功能,请直接使用 @context-chef/core。
许可证
MIT
