Commit 0cb55d96826d9c08222bd3bfe6ab013e8779ea84
1 parent
a9d442f2
fix(agent-chat): 同 convId 恢复上下文时按模型上下文动态截断历史
原来场景 C 固定只回放最近 20 条、单条截断到 500 字,ACP 进程重启/空闲超时 后早于窗口的历史会静默丢失。改成按 token 预算(默认按 1M 上下文的一半, AGENT_MODEL_CONTEXT_TOKENS 可覆盖)从最新往最老收,装得下就整段注入。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XUvQ2xSFk7YoavE5SQENdw
Showing
2 changed files
with
87 additions
and
5 deletions
server/routes/agent-chat.js
| ... | ... | @@ -1067,6 +1067,30 @@ function buildUploadContext(id, convId) { |
| 1067 | 1067 | return contextBlock; |
| 1068 | 1068 | } |
| 1069 | 1069 | |
| 1070 | +// 模型上下文预算 —— 现在跑的都是 1M 上下文的模型,历史记录分一半,剩下留给 system prompt / 工具产出 / 回复。 | |
| 1071 | +// 换更小上下文的模型时改 AGENT_MODEL_CONTEXT_TOKENS 就行,不用改这段逻辑。 | |
| 1072 | +const MODEL_CONTEXT_TOKENS = parseInt(process.env.AGENT_MODEL_CONTEXT_TOKENS || '1000000', 10); | |
| 1073 | +const HISTORY_TOKEN_BUDGET = Math.floor(MODEL_CONTEXT_TOKENS * 0.5); | |
| 1074 | +// ponytail: 4 字符≈1 token 的粗估算,不是真实分词器;预算掐得准不准全看这条, | |
| 1075 | +// 真出现估算偏差导致截断过早/过晚,再换成真实 tokenizer(如 tiktoken)计数 | |
| 1076 | +const CHARS_PER_TOKEN = 4; | |
| 1077 | +function estimateTokens(text) { | |
| 1078 | + return Math.ceil(text.length / CHARS_PER_TOKEN); | |
| 1079 | +} | |
| 1080 | + | |
| 1081 | +/** 按 token 预算从最新往最老收,超预算就把更老的历史丢掉(至少保留最近一条) */ | |
| 1082 | +function truncateHistoryToBudget(history, budget) { | |
| 1083 | + const kept = []; | |
| 1084 | + let used = 0; | |
| 1085 | + for (let i = history.length - 1; i >= 0; i--) { | |
| 1086 | + const cost = estimateTokens(history[i].content); | |
| 1087 | + if (kept.length > 0 && used + cost > budget) break; | |
| 1088 | + used += cost; | |
| 1089 | + kept.unshift(history[i]); | |
| 1090 | + } | |
| 1091 | + return kept; | |
| 1092 | +} | |
| 1093 | + | |
| 1070 | 1094 | function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) { |
| 1071 | 1095 | // 注入上传文件上下文(按寻址名取,与上传端点的落点是同一份推导) |
| 1072 | 1096 | const uploadContext = buildUploadContext(agentId, convId); |
| ... | ... | @@ -1086,12 +1110,11 @@ function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) { |
| 1086 | 1110 | return `${systemPrompt}${cronInstructions}\n\n---\n\n## 用户消息\n\n${enrichedMessage}`; |
| 1087 | 1111 | } |
| 1088 | 1112 | |
| 1089 | - // 场景 C: 恢复上下文 — 注入历史摘要 | |
| 1090 | - const recentHistory = history.slice(-20); | |
| 1091 | - const historyText = recentHistory.map(m => { | |
| 1113 | + // 场景 C: 恢复上下文 — 按模型上下文动态算预算,装得下就整段注入,装不下从最早的开始丢 | |
| 1114 | + const boundedHistory = truncateHistoryToBudget(history, HISTORY_TOKEN_BUDGET); | |
| 1115 | + const historyText = boundedHistory.map(m => { | |
| 1092 | 1116 | const role = m.role === 'user' ? 'Human' : 'Assistant'; |
| 1093 | - const content = m.content.length > 500 ? m.content.slice(0, 500) + '...(截断)' : m.content; | |
| 1094 | - return `${role}: ${content}`; | |
| 1117 | + return `${role}: ${m.content}`; | |
| 1095 | 1118 | }).join('\n\n'); |
| 1096 | 1119 | |
| 1097 | 1120 | return `${systemPrompt}${cronInstructions}\n\n---\n\n## 之前的对话历史(供你参考以保持上下文连贯)\n\n${historyText}\n\n---\n\n## 用户新消息\n\n${enrichedMessage}`; |
| ... | ... | @@ -2916,3 +2939,6 @@ module.exports.saveMeta = saveMeta; |
| 2916 | 2939 | module.exports.appendHistory = appendHistory; |
| 2917 | 2940 | module.exports.logMessage = logMessage; |
| 2918 | 2941 | module.exports.flushLogBufferForConv = flushLogBufferForConv; |
| 2942 | +module.exports.buildPrompt = buildPrompt; | |
| 2943 | +module.exports.truncateHistoryToBudget = truncateHistoryToBudget; | |
| 2944 | +module.exports.estimateTokens = estimateTokens; | ... | ... |
server/tests/unit/agent-chat-history-context.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Guard: 同一 convId 恢复上下文时(buildPrompt 场景 C)按模型上下文 token 预算动态注入历史, | |
| 3 | + * 不再是固定的 slice(-20) + 单条 500 字截断 —— 预算内整段塞进去,超预算才从最早的开始丢。 | |
| 4 | + * 否则 ACP 进程重启/空闲超时后,早于第 20 条的历史会静默丢失,agent 会"忘了"之前聊过什么。 | |
| 5 | + */ | |
| 6 | +const fs = require('fs'); | |
| 7 | +const path = require('path'); | |
| 8 | + | |
| 9 | +const { buildPrompt, appendHistory, truncateHistoryToBudget, estimateTokens } = require('../../routes/agent-chat'); | |
| 10 | + | |
| 11 | +const agentId = 'master'; | |
| 12 | +const agentDir = path.resolve(__dirname, `../../../system/agents/${agentId}`); | |
| 13 | + | |
| 14 | +describe('agent-chat buildPrompt 场景 C:同 convId 恢复上下文', () => { | |
| 15 | + it('注入完整历史,不按 slice(-20) 或单条 500 字截断', () => { | |
| 16 | + const convId = `test-history-guard-${Date.now()}`; | |
| 17 | + const convDir = path.resolve(__dirname, `../../../system/agents/${agentId}/_global/conversations/${convId}`); | |
| 18 | + try { | |
| 19 | + const longFirstMessage = 'X'.repeat(600); // 超过原来单条 500 字截断阈值 | |
| 20 | + appendHistory(agentId, convId, { role: 'user', content: longFirstMessage }); | |
| 21 | + for (let i = 0; i < 25; i++) { | |
| 22 | + appendHistory(agentId, convId, { role: 'assistant', content: `filler-${i}` }); | |
| 23 | + } | |
| 24 | + | |
| 25 | + const prompt = buildPrompt(agentDir, agentId, convId, '新消息', true); | |
| 26 | + | |
| 27 | + expect(prompt).toContain(longFirstMessage); | |
| 28 | + expect(prompt).not.toContain('...(截断)'); | |
| 29 | + } finally { | |
| 30 | + fs.rmSync(convDir, { recursive: true, force: true }); | |
| 31 | + } | |
| 32 | + }); | |
| 33 | +}); | |
| 34 | + | |
| 35 | +describe('truncateHistoryToBudget:预算超支时按 token 从最早的开始丢', () => { | |
| 36 | + it('预算装不下全部历史时,丢最老的,留最近的', () => { | |
| 37 | + const history = [ | |
| 38 | + { role: 'user', content: 'oldest' }, | |
| 39 | + { role: 'assistant', content: 'middle' }, | |
| 40 | + { role: 'user', content: 'newest' }, | |
| 41 | + ]; | |
| 42 | + const budget = estimateTokens('middle') + estimateTokens('newest'); // 刚好够后两条,装不下 oldest | |
| 43 | + | |
| 44 | + const kept = truncateHistoryToBudget(history, budget); | |
| 45 | + | |
| 46 | + expect(kept.map(m => m.content)).toEqual(['middle', 'newest']); | |
| 47 | + }); | |
| 48 | + | |
| 49 | + it('单条消息本身就超预算时,仍然至少保留最近一条,不会返回空', () => { | |
| 50 | + const history = [{ role: 'user', content: 'X'.repeat(10000) }]; | |
| 51 | + | |
| 52 | + const kept = truncateHistoryToBudget(history, 1); | |
| 53 | + | |
| 54 | + expect(kept).toHaveLength(1); | |
| 55 | + }); | |
| 56 | +}); | ... | ... |