Files
2026-08-01 15:49:53 +08:00

286 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 上下文压缩
*
* exec 的输出极易吃满上下文:一次 `journalctl` 或 `df -h` 就是几千 token,
* 十几轮下来必然超限。session-store 的按轮次裁剪只是硬丢,会让 agent 忘掉
* 前面已经查明的事实,反复重跑同样的命令。这里补上摘要压缩。
*
* 策略:
* 保留最近 N 轮原文 + 把更早的部分压成一段摘要,摘要以 user/assistant
* 问答对的形式回填。不用 system 消息,因为部分厂商只接受单条 system。
*
* 两个触发点:
* 1. 主动 —— 发请求前估算超过阈值
* 2. 被动 —— 厂商返回上下文超限错误后强制压缩重试一次
*
* token 估算天然不准(不同厂商分词器不同),所以被动这条不能省。
*/
import { generateText } from 'ai'
// 默认上下文预算。第三方模型五花八门,取一个多数模型都扛得住的保守值,
// 用户可在 AI 配置里用 contextLimit 覆盖。
export const DEFAULT_CONTEXT_LIMIT = 64 * 1024
// 达到预算的这个比例就触发压缩,留出本轮输出的空间
const COMPACT_RATIO = 0.7
// 保留原文的最近轮数
const KEEP_RECENT_TURNS = 3
// 摘要本身的长度上限
const SUMMARY_MAX_TOKENS = 800
// 单条工具输出进入摘要素材时的截断长度
const TRANSCRIPT_TOOL_LIMIT = 800
/**
* 粗略估算 token 数。
*
* 中英文密度差别很大:英文约 4 字符/token,中文约 1.5 字符/token。
* 只按字符数除以固定值会在中文场景严重低估,反而更容易撞上限。
*/
export function estimateTokens(input) {
if (input === null || input === undefined) return 0
const text = typeof input === 'string' ? input : JSON.stringify(input)
if (!text) return 0
let cjk = 0
for (const char of text) {
const code = char.codePointAt(0)
// CJK 统一表意文字 + 日文假名 + 全角标点
if ((code >= 0x4e00 && code <= 0x9fff)
|| (code >= 0x3040 && code <= 0x30ff)
|| (code >= 0xff00 && code <= 0xffef)
|| (code >= 0x3000 && code <= 0x303f)) {
cjk += 1
}
}
const other = text.length - cjk
return Math.ceil(cjk / 1.5 + other / 4)
}
export function estimateMessagesTokens(messages = []) {
// 每条消息有固定开销(role、分隔符),按 4 token 计
return messages.reduce((total, message) => total + estimateTokens(message) + 4, 0)
}
function toArray(content) {
if (Array.isArray(content)) return content
if (content === null || content === undefined) return []
return [{ type: 'text', text: String(content) }]
}
function truncate(text, limit) {
const value = typeof text === 'string' ? text : JSON.stringify(text ?? '')
if (value.length <= limit) return value
return `${ value.slice(0, limit) }…(已截断,原长 ${ value.length } 字符)`
}
/**
* 把消息渲染成纯文本笔录,作为摘要素材。
*
* 不直接把 ModelMessage[] 丢给 generateText,因为那样必须保证 tool-call
* 与 tool-result 严格配对,而待压缩的这一段恰恰可能被截断过。渲染成文本
* 就绕开了这个约束,也更好控制素材长度。
*/
export function renderTranscript(messages = []) {
const lines = []
for (const message of messages) {
if (message.role === 'user') {
lines.push(`【用户】${ truncate(toArray(message.content).map((part) => part.text || '').join(''), 1000) }`)
continue
}
if (message.role === 'assistant') {
for (const part of toArray(message.content)) {
if (part.type === 'text' && part.text?.trim()) {
lines.push(`【助手】${ truncate(part.text, 1000) }`)
} else if (part.type === 'tool-call') {
lines.push(`【调用】${ part.toolName } ${ truncate(part.input, 300) }`)
}
}
continue
}
if (message.role === 'tool') {
for (const part of toArray(message.content)) {
if (part.type !== 'tool-result') continue
const value = part.output?.value ?? part.output
const isError = String(part.output?.type || '').startsWith('error')
lines.push(`【结果${ isError ? '·失败' : '' }】${ truncate(value, TRANSCRIPT_TOOL_LIMIT) }`)
}
}
}
return lines.join('\n')
}
const SUMMARY_PROMPT = `你在为一个 Linux 服务器 AI 助手压缩对话历史。请把下面的笔录浓缩成一段结构化摘要,供助手继续工作时参考。
必须保留:
1. **已执行的操作** —— 在哪台主机上做了什么,结果成功还是失败
2. **已查明的事实** —— 具体的数值、路径、版本、配置项、错误信息,原样保留不要概括成"某些配置"
3. **未完成的事项** —— 计划了但还没做的步骤
4. **用户的要求与偏好** —— 明确提出的约束、否决过的方案
要求:
- 用中文,分条列出,不要客套话
- 宁可保留具体数据也不要为了简短而丢失细节,这些数据助手后面还要用
- 不要臆测笔录里没有的内容
- 只输出摘要正文`
/**
* 找到安全的切分点:从 keepRecentTurns 轮之前的那条 user 消息处切。
*
* 必须切在 user 消息上,否则会把 assistant 的 tool-call 和它的 tool-result
* 拆到两边,留下的部分就是残缺历史。
*
* @returns {number} 切分下标;-1 表示没有可切的点
*/
export function findSplitIndex(messages, keepRecentTurns = KEEP_RECENT_TURNS) {
const userIndexes = []
for (let i = 0; i < messages.length; i += 1) {
if (messages[i].role === 'user') userIndexes.push(i)
}
// 轮数不够,没什么可压的
if (userIndexes.length <= keepRecentTurns) return -1
const splitAt = userIndexes[userIndexes.length - keepRecentTurns]
// 切掉的部分太短,压缩收益抵不上一次模型调用
return splitAt >= 2 ? splitAt : -1
}
/** 摘要回填成一问一答,兼容只允许单条 system 的厂商 */
export function buildSummaryMessages(summary, droppedCount) {
return [
{
role: 'user',
content: `[上下文摘要] 为节省上下文,此前 ${ droppedCount } 条对话已被压缩。以下是其中的关键信息,请在后续工作中继续参考:\n\n${ summary }`
},
{
role: 'assistant',
content: '已了解以上背景,我会基于这些信息继续。'
}
]
}
/**
* 压缩消息历史。
*
* @param {object} params
* @param {Array} params.messages
* @param {object} [params.model] AI SDK 模型实例,用于生成摘要
* @param {number} [params.contextLimit]
* @param {number} [params.keepRecentTurns]
* @param {boolean} [params.force] 忽略阈值强制压缩(用于超限重试)
* @param {AbortSignal} [params.signal]
* @param {(transcript: string) => Promise<string>} [params.summarize] 注入摘要实现,便于测试
* @returns {Promise<{ messages, compacted, summary, droppedCount, beforeTokens, afterTokens, reason }>}
*/
export async function compactMessages(params) {
const {
messages = [],
model,
contextLimit = DEFAULT_CONTEXT_LIMIT,
keepRecentTurns = KEEP_RECENT_TURNS,
force = false,
signal
} = params
const beforeTokens = estimateMessagesTokens(messages)
const threshold = Math.floor(contextLimit * COMPACT_RATIO)
const unchanged = (reason) => ({
messages,
compacted: false,
summary: '',
droppedCount: 0,
splitAt: -1,
beforeTokens,
afterTokens: beforeTokens,
reason
})
if (!force && beforeTokens < threshold) return unchanged('under-threshold')
const splitAt = findSplitIndex(messages, keepRecentTurns)
// 强制压缩时退一步,只保留最后一轮,尽可能腾出空间
const effectiveSplit = splitAt === -1 && force ? findSplitIndex(messages, 1) : splitAt
if (effectiveSplit === -1) return unchanged('nothing-to-compact')
const older = messages.slice(0, effectiveSplit)
const recent = messages.slice(effectiveSplit)
// 连续压缩时要把上一次的摘要一并喂进去,否则更早的信息会在第二次
// 压缩时彻底丢失
const transcript = [
params.previousSummary ? `【此前的摘要】\n${ params.previousSummary }` : '',
renderTranscript(older)
].filter(Boolean).join('\n\n')
if (!transcript.trim()) return unchanged('empty-transcript')
const summarize = params.summarize || (async (text) => {
if (!model) throw new Error('缺少用于生成摘要的模型')
const result = await generateText({
model,
system: SUMMARY_PROMPT,
prompt: text,
maxOutputTokens: SUMMARY_MAX_TOKENS,
abortSignal: signal
})
return result.text
})
let summary
try {
summary = (await summarize(transcript)).trim()
} catch (error) {
// 摘要失败不能让整个 turn 挂掉。降级为直接丢弃旧消息 ——
// 丢了上下文总比整个请求失败强,但要让用户知道发生了什么。
logger.warn(`[ai-compaction] 生成摘要失败,降级为直接裁剪: ${ error.message }`)
const fallback = [...recent]
return {
messages: fallback,
compacted: true,
degraded: true,
summary: '',
droppedCount: older.length,
splitAt: effectiveSplit,
beforeTokens,
afterTokens: estimateMessagesTokens(fallback),
reason: `summary-failed: ${ error.message }`
}
}
if (!summary) return unchanged('empty-summary')
const compactedMessages = [...buildSummaryMessages(summary, older.length), ...recent]
return {
messages: compactedMessages,
compacted: true,
summary,
droppedCount: older.length,
splitAt: effectiveSplit,
beforeTokens,
afterTokens: estimateMessagesTokens(compactedMessages),
reason: force ? 'forced' : 'over-threshold'
}
}
/** 判断一个错误是不是上下文超限,用于决定要不要强制压缩重试 */
export function isContextLengthError(error) {
const message = String(error?.message || error || '').toLowerCase()
const status = error?.statusCode || error?.status
if (status === 413) return true
return [
'context length',
'context_length_exceeded',
'maximum context',
'too many tokens',
'reduce the length',
'request too large',
'prompt is too long',
'input length and `max_tokens` exceed'
].some((keyword) => message.includes(keyword))
}