From 0cb55d96826d9c08222bd3bfe6ab013e8779ea84 Mon Sep 17 00:00:00 2001 From: yibo Date: Sun, 30 Aug 2026 23:53:19 +0800 Subject: [PATCH] fix(agent-chat): 同 convId 恢复上下文时按模型上下文动态截断历史 --- server/routes/agent-chat.js | 36 +++++++++++++++++++++++++++++++----- server/tests/unit/agent-chat-history-context.test.js | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 server/tests/unit/agent-chat-history-context.test.js diff --git a/server/routes/agent-chat.js b/server/routes/agent-chat.js index d93b486..86867f3 100644 --- a/server/routes/agent-chat.js +++ b/server/routes/agent-chat.js @@ -1067,6 +1067,30 @@ function buildUploadContext(id, convId) { return contextBlock; } +// 模型上下文预算 —— 现在跑的都是 1M 上下文的模型,历史记录分一半,剩下留给 system prompt / 工具产出 / 回复。 +// 换更小上下文的模型时改 AGENT_MODEL_CONTEXT_TOKENS 就行,不用改这段逻辑。 +const MODEL_CONTEXT_TOKENS = parseInt(process.env.AGENT_MODEL_CONTEXT_TOKENS || '1000000', 10); +const HISTORY_TOKEN_BUDGET = Math.floor(MODEL_CONTEXT_TOKENS * 0.5); +// ponytail: 4 字符≈1 token 的粗估算,不是真实分词器;预算掐得准不准全看这条, +// 真出现估算偏差导致截断过早/过晚,再换成真实 tokenizer(如 tiktoken)计数 +const CHARS_PER_TOKEN = 4; +function estimateTokens(text) { + return Math.ceil(text.length / CHARS_PER_TOKEN); +} + +/** 按 token 预算从最新往最老收,超预算就把更老的历史丢掉(至少保留最近一条) */ +function truncateHistoryToBudget(history, budget) { + const kept = []; + let used = 0; + for (let i = history.length - 1; i >= 0; i--) { + const cost = estimateTokens(history[i].content); + if (kept.length > 0 && used + cost > budget) break; + used += cost; + kept.unshift(history[i]); + } + return kept; +} + function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) { // 注入上传文件上下文(按寻址名取,与上传端点的落点是同一份推导) const uploadContext = buildUploadContext(agentId, convId); @@ -1086,12 +1110,11 @@ function buildPrompt(agentDir, agentId, convId, userMessage, isNewSession) { return `${systemPrompt}${cronInstructions}\n\n---\n\n## 用户消息\n\n${enrichedMessage}`; } - // 场景 C: 恢复上下文 — 注入历史摘要 - const recentHistory = history.slice(-20); - const historyText = recentHistory.map(m => { + // 场景 C: 恢复上下文 — 按模型上下文动态算预算,装得下就整段注入,装不下从最早的开始丢 + const boundedHistory = truncateHistoryToBudget(history, HISTORY_TOKEN_BUDGET); + const historyText = boundedHistory.map(m => { const role = m.role === 'user' ? 'Human' : 'Assistant'; - const content = m.content.length > 500 ? m.content.slice(0, 500) + '...(截断)' : m.content; - return `${role}: ${content}`; + return `${role}: ${m.content}`; }).join('\n\n'); 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; module.exports.appendHistory = appendHistory; module.exports.logMessage = logMessage; module.exports.flushLogBufferForConv = flushLogBufferForConv; +module.exports.buildPrompt = buildPrompt; +module.exports.truncateHistoryToBudget = truncateHistoryToBudget; +module.exports.estimateTokens = estimateTokens; diff --git a/server/tests/unit/agent-chat-history-context.test.js b/server/tests/unit/agent-chat-history-context.test.js new file mode 100644 index 0000000..3029ff2 --- /dev/null +++ b/server/tests/unit/agent-chat-history-context.test.js @@ -0,0 +1,56 @@ +/** + * Guard: 同一 convId 恢复上下文时(buildPrompt 场景 C)按模型上下文 token 预算动态注入历史, + * 不再是固定的 slice(-20) + 单条 500 字截断 —— 预算内整段塞进去,超预算才从最早的开始丢。 + * 否则 ACP 进程重启/空闲超时后,早于第 20 条的历史会静默丢失,agent 会"忘了"之前聊过什么。 + */ +const fs = require('fs'); +const path = require('path'); + +const { buildPrompt, appendHistory, truncateHistoryToBudget, estimateTokens } = require('../../routes/agent-chat'); + +const agentId = 'master'; +const agentDir = path.resolve(__dirname, `../../../system/agents/${agentId}`); + +describe('agent-chat buildPrompt 场景 C:同 convId 恢复上下文', () => { + it('注入完整历史,不按 slice(-20) 或单条 500 字截断', () => { + const convId = `test-history-guard-${Date.now()}`; + const convDir = path.resolve(__dirname, `../../../system/agents/${agentId}/_global/conversations/${convId}`); + try { + const longFirstMessage = 'X'.repeat(600); // 超过原来单条 500 字截断阈值 + appendHistory(agentId, convId, { role: 'user', content: longFirstMessage }); + for (let i = 0; i < 25; i++) { + appendHistory(agentId, convId, { role: 'assistant', content: `filler-${i}` }); + } + + const prompt = buildPrompt(agentDir, agentId, convId, '新消息', true); + + expect(prompt).toContain(longFirstMessage); + expect(prompt).not.toContain('...(截断)'); + } finally { + fs.rmSync(convDir, { recursive: true, force: true }); + } + }); +}); + +describe('truncateHistoryToBudget:预算超支时按 token 从最早的开始丢', () => { + it('预算装不下全部历史时,丢最老的,留最近的', () => { + const history = [ + { role: 'user', content: 'oldest' }, + { role: 'assistant', content: 'middle' }, + { role: 'user', content: 'newest' }, + ]; + const budget = estimateTokens('middle') + estimateTokens('newest'); // 刚好够后两条,装不下 oldest + + const kept = truncateHistoryToBudget(history, budget); + + expect(kept.map(m => m.content)).toEqual(['middle', 'newest']); + }); + + it('单条消息本身就超预算时,仍然至少保留最近一条,不会返回空', () => { + const history = [{ role: 'user', content: 'X'.repeat(10000) }]; + + const kept = truncateHistoryToBudget(history, 1); + + expect(kept).toHaveLength(1); + }); +}); -- libgit2 0.21.4