Final Project / 06
Context & Memory 实现
Agent 失败最常见的原因不是模型不聪明,而是上下文被污染了。
一场面试诊断可能有 20 道题,每题需要:用户回答(~200 tokens)+ 知识库参考答案×3(~1500 tokens)+ 诊断结果(~500 tokens)。20 题就是 44000 tokens 的工具输出——还没算 system prompt 和历史对话。
如果不做任何管理,第 5 题的时候上下文就超窗口了。模型要么报错,要么开始幻觉。
Context 层解决“当前任务看什么”,Memory 层解决“跨会话记什么”。
模块结构
src/context/
├── manager.ts # ContextManager 主类
├── compressor.ts # 压缩策略实现
├── budget.ts # Token 预算追踪
├── system-prompt.ts # System prompt 模板
└── types.ts # 类型定义
src/memory/
├── store.ts # MemoryStore(SQLite)
├── retriever.ts # 记忆召回策略
├── profile.ts # 用户画像管理
├── triggers.ts # 自动写入触发器
└── types.ts # 类型定义
Context Manager:核心设计
信息分层模型
上下文不是“把所有东西塞进去”,而是分层管理,每层有独立的生命周期和 token 预算。

为什么不用满 200k 窗口?两个原因:
- 成本:input tokens 按量计费,20 题诊断如果每次都带 100k 上下文,成本会翻 10 倍
- 注意力稀释:窗口越长,模型对关键信息的注意力越分散,诊断质量反而下降
ContextManager 实现
// context/types.ts
export interface ContextConfig {
maxTotalTokens: number; // 总窗口上限
systemPromptBudget: number; // system prompt 预算
profileBudget: number; // 用户画像预算
taskBudget: number; // 当前任务预算
historyBudget: number; // 历史摘要预算
recentBudget: number; // 最近消息预算
outputReserve: number; // 输出保留空间
}
export const DEFAULT_CONTEXT_CONFIG: ContextConfig = {
maxTotalTokens: 16000,
systemPromptBudget: 2000,
profileBudget: 500,
taskBudget: 4000,
historyBudget: 2000,
recentBudget: 3000,
outputReserve: 4000,
};
export interface ContextWindow {
systemPrompt: string;
messages: Message[];
tokenCount: number;
layers: {
system: number;
profile: number;
task: number;
history: number;
recent: number;
};
}
// context/manager.ts
import { ContextConfig, ContextWindow, DEFAULT_CONTEXT_CONFIG } from './types';
import { Compressor } from './compressor';
import { TokenBudget } from './budget';
export class ContextManager {
private config: ContextConfig;
private compressor: Compressor;
private budget: TokenBudget;
private systemPrompt: string = '';
private profileBlock: string = '';
private taskBlock: string = '';
private historySummary: string = '';
private recentMessages: Message[] = [];
private allMessages: Message[] = [];
constructor(config: Partial<ContextConfig> = {}) {
this.config = { ...DEFAULT_CONTEXT_CONFIG, ...config };
this.compressor = new Compressor();
this.budget = new TokenBudget(this.config);
}
setSystemPrompt(prompt: string): void {
this.systemPrompt = this.compressor.truncate(prompt, this.config.systemPromptBudget);
}
setProfile(profile: string): void {
this.profileBlock = this.compressor.truncate(profile, this.config.profileBudget);
}
setTaskContext(context: string): void {
this.taskBlock = this.compressor.truncate(context, this.config.taskBudget);
}
addMessage(message: Message): void {
this.allMessages.push(message);
this.recentMessages.push(message);
// 滑动窗口:只保留最近 N 条
while (this.countTokens(this.recentMessages) > this.config.recentBudget) {
const evicted = this.recentMessages.shift()!;
// 被踢出的消息进入历史摘要队列
this.appendToHistory(evicted);
}
}
addToolResult(toolCallId: string, result: string): void {
// 工具输出可能很长,先压缩再加入
const compressed = this.compressor.compressToolOutput(result, 1000);
this.addMessage({
role: 'tool',
toolCallId,
content: compressed,
});
}
build(): ContextWindow {
const fullSystem = this.buildSystemBlock();
const messages = this.buildMessages();
const tokenCount = this.countTokens(messages) + this.estimateTokens(fullSystem);
return {
systemPrompt: fullSystem,
messages,
tokenCount,
layers: {
system: this.estimateTokens(this.systemPrompt),
profile: this.estimateTokens(this.profileBlock),
task: this.estimateTokens(this.taskBlock),
history: this.estimateTokens(this.historySummary),
recent: this.countTokens(this.recentMessages),
},
};
}
needsCompaction(): boolean {
const current = this.build().tokenCount;
const threshold = this.config.maxTotalTokens - this.config.outputReserve;
return current > threshold * 0.9; // 90% 时预警
}
async autoCompact(queryEngine: QueryEngine): Promise<void> {
if (!this.needsCompaction()) return;
// Level 1: 压缩工具输出
this.compressRecentToolOutputs();
if (this.needsCompaction()) {
// Level 2: 压缩历史为摘要
this.historySummary = await this.compressor.summarizeHistory(
this.historySummary,
this.recentMessages.slice(0, -3), // 保留最近 3 条不压缩
queryEngine,
);
this.recentMessages = this.recentMessages.slice(-3);
}
if (this.needsCompaction()) {
// Level 3: 激进压缩——任务上下文也压缩
this.taskBlock = await this.compressor.summarize(this.taskBlock, queryEngine, 1000);
}
}
getStats(): string {
const window = this.build();
const used = window.tokenCount;
const max = this.config.maxTotalTokens;
return `Context: ${used}/${max} tokens (${Math.round(used / max * 100)}%) | ` +
`system:${window.layers.system} profile:${window.layers.profile} ` +
`task:${window.layers.task} history:${window.layers.history} recent:${window.layers.recent}`;
}
private buildSystemBlock(): string {
const parts: string[] = [this.systemPrompt];
if (this.profileBlock) parts.push(`\n## 用户背景\n${this.profileBlock}`);
if (this.historySummary) parts.push(`\n## 已完成诊断摘要\n${this.historySummary}`);
return parts.join('\n');
}
private buildMessages(): Message[] {
const messages: Message[] = [];
// 任务上下文作为第一条 user 消息注入
if (this.taskBlock) {
messages.push({ role: 'user', content: this.taskBlock });
messages.push({ role: 'assistant', content: '好的,我已了解当前任务上下文。请继续。' });
}
// 最近消息
messages.push(...this.recentMessages);
return messages;
}
private appendToHistory(message: Message): void {
// 简单追加,等 autoCompact 时统一压缩
if (message.role === 'assistant' && message.content) {
this.historySummary += `\n- ${message.content.slice(0, 100)}`;
}
}
private compressRecentToolOutputs(): void {
this.recentMessages = this.recentMessages.map(msg => {
if (msg.role === 'tool' && msg.content && msg.content.length > 500) {
return { ...msg, content: this.compressor.compressToolOutput(msg.content, 500) };
}
return msg;
});
}
private countTokens(messages: Message[]): number {
return messages.reduce((sum, m) => sum + this.estimateTokens(m.content ?? ''), 0);
}
private estimateTokens(text: string): number {
// 粗估:中文 ~1.5 token/字,英文 ~0.25 token/字
const cjk = (text.match(/[一-鿿]/g) || []).length;
const ascii = text.length - cjk;
return Math.ceil(cjk * 1.5 + ascii * 0.25);
}
}
Compressor:压缩策略
// context/compressor.ts
export class Compressor {
truncate(text: string, maxTokens: number): string {
const estimated = this.estimateTokens(text);
if (estimated <= maxTokens) return text;
// 按比例截断
const ratio = maxTokens / estimated;
const maxChars = Math.floor(text.length * ratio * 0.9); // 留 10% 余量
return text.slice(0, maxChars) + '\n\n[... 已截断]';
}
compressToolOutput(output: string, maxTokens: number): string {
const estimated = this.estimateTokens(output);
if (estimated <= maxTokens) return output;
// 策略:保留 JSON 的 key 结构 + 值的前 N 字符
try {
const parsed = JSON.parse(output);
return JSON.stringify(this.compressObject(parsed, maxTokens), null, 0);
} catch {
// 非 JSON,直接截断
return this.truncate(output, maxTokens);
}
}
async summarizeHistory(
existingSummary: string,
messages: Message[],
queryEngine: QueryEngine,
): Promise<string> {
const content = messages
.filter(m => m.role === 'assistant' && m.content)
.map(m => m.content!.slice(0, 200))
.join('\n');
if (!content) return existingSummary;
const response = await queryEngine.query({
task: 'knowledge_summary',
messages: [{
role: 'user',
content: `请将以下对话历史压缩为简洁摘要(保留关键结论和数据,删除过程细节):\n\n已有摘要:\n${existingSummary}\n\n新增内容:\n${content}`,
}],
systemPrompt: '你是一个对话压缩器。输出尽量简洁,用 bullet points,保留数字和关键结论。',
maxTokens: 500,
});
return response.content ?? existingSummary;
}
async summarize(text: string, queryEngine: QueryEngine, maxTokens: number): Promise<string> {
const response = await queryEngine.query({
task: 'knowledge_summary',
messages: [{ role: 'user', content: `请将以下内容压缩到 ${maxTokens} tokens 以内,保留关键信息:\n\n${text}` }],
maxTokens: Math.min(maxTokens, 1000),
});
return response.content ?? this.truncate(text, maxTokens);
}
private compressObject(obj: any, maxTokens: number): any {
if (typeof obj === 'string') {
return obj.length > 100 ? obj.slice(0, 100) + '...' : obj;
}
if (Array.isArray(obj)) {
// 数组只保留前 3 项
return obj.slice(0, 3).map(item => this.compressObject(item, maxTokens));
}
if (typeof obj === 'object' && obj !== null) {
const result: any = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = this.compressObject(value, maxTokens);
}
return result;
}
return obj;
}
private estimateTokens(text: string): number {
const cjk = (text.match(/[一-鿿]/g) || []).length;
const ascii = text.length - cjk;
return Math.ceil(cjk * 1.5 + ascii * 0.25);
}
}
面试诊断场景的 Context 生命周期
sequenceDiagram
participant User
participant CM as Context Manager
participant QE as Query Engine
participant LLM
User->>CM: 上传面试稿(20 题)
CM->>CM: setSystemPrompt(诊断标准)
CM->>CM: setProfile(用户画像)
loop 每道题
CM->>CM: setTaskContext(Q&A + 参考答案)
CM->>CM: build() → ContextWindow
CM->>LLM: 带完整上下文调用
LLM-->>CM: 诊断结果
CM->>CM: addMessage(result)
CM->>CM: needsCompaction()?
alt 需要压缩
CM->>QE: autoCompact → 摘要历史
QE-->>CM: compressed summary
end
end
Note over CM: 第 1 题: task=新上下文, history=空<br/>第 5 题: task=新上下文, history=Q1-Q4摘要<br/>第 20 题: task=新上下文, history=Q1-Q19摘要(压缩版)
Memory Store:跨会话记忆
Memory 解决的问题和 Context 不同:Context 管理“这一次对话看什么”,Memory 管理“跨多次对话记什么”。
记忆类型
// memory/types.ts
export type MemoryType = 'user_profile' | 'diagnosis_summary' | 'weak_point' | 'preference';
export interface MemoryEntry {
id: string;
type: MemoryType;
key: string; // 语义标识符
value: unknown; // 结构化数据
confidence: number; // 0-1,多次验证后提升
createdAt: string;
updatedAt: string;
expiresAt?: string; // 短期记忆有过期
accessCount: number; // 被召回次数
}
export interface UserProfile {
targetRole?: string; // 目标岗位
techStack?: string[]; // 技术栈
experience?: string; // 工作经验
weakDimensions?: string[]; // 历次诊断中暴露的弱项
strongDimensions?: string[]; // 强项
lastDiagnosisScore?: number; // 上次诊断总分
totalDiagnoses?: number; // 累计诊断次数
scoreHistory?: Array<{ date: string; score: number }>;
}
MemoryStore 实现
// memory/store.ts
import Database from 'better-sqlite3';
import { MemoryEntry, MemoryType, UserProfile } from './types';
export class MemoryStore {
private db: Database.Database;
constructor(dbPath: string) {
this.db = new Database(dbPath);
this.db.pragma('journal_mode = WAL');
this.initSchema();
}
private initSchema(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 0.5,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
access_count INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
CREATE INDEX IF NOT EXISTS idx_memories_key ON memories(key);
CREATE INDEX IF NOT EXISTS idx_memories_type_key ON memories(type, key);
`);
}
create(type: MemoryType, key: string, value: unknown): string {
const id = `${type}:${key}:${Date.now()}`;
this.db.prepare(`
INSERT INTO memories (id, type, key, value, confidence)
VALUES (?, ?, ?, ?, 0.5)
`).run(id, type, key, JSON.stringify(value));
return id;
}
retrieve(opts: {
type?: MemoryType;
key?: string;
limit?: number;
minConfidence?: number;
} = {}): MemoryEntry[] {
let sql = 'SELECT * FROM memories WHERE 1=1';
const params: any[] = [];
if (opts.type) {
sql += ' AND type = ?';
params.push(opts.type);
}
if (opts.key) {
sql += ' AND key LIKE ?';
params.push(`%${opts.key}%`);
}
if (opts.minConfidence) {
sql += ' AND confidence >= ?';
params.push(opts.minConfidence);
}
sql += ' AND (expires_at IS NULL OR expires_at > datetime("now"))';
sql += ' ORDER BY confidence DESC, updated_at DESC';
if (opts.limit) {
sql += ' LIMIT ?';
params.push(opts.limit);
}
const rows = this.db.prepare(sql).all(...params);
// 记录访问次数
for (const row of rows) {
this.db.prepare('UPDATE memories SET access_count = access_count + 1 WHERE id = ?')
.run((row as any).id);
}
return rows.map(r => this.rowToEntry(r));
}
update(id: string, value: unknown, boostConfidence?: boolean): void {
const updates: string[] = ['value = ?', 'updated_at = datetime("now")'];
const params: any[] = [JSON.stringify(value)];
if (boostConfidence) {
updates.push('confidence = MIN(1.0, confidence + 0.1)');
}
this.db.prepare(`UPDATE memories SET ${updates.join(', ')} WHERE id = ?`)
.run(...params, id);
}
delete(id: string): void {
this.db.prepare('DELETE FROM memories WHERE id = ?').run(id);
}
deleteAll(type?: MemoryType): void {
if (type) {
this.db.prepare('DELETE FROM memories WHERE type = ?').run(type);
} else {
this.db.prepare('DELETE FROM memories').run();
}
}
// 用户画像快捷方法
getProfile(): UserProfile {
const entries = this.retrieve({ type: 'user_profile' });
const profile: UserProfile = {};
for (const entry of entries) {
Object.assign(profile, entry.value);
}
return profile;
}
updateProfile(patch: Partial<UserProfile>): void {
const existing = this.retrieve({ type: 'user_profile', key: 'main' });
if (existing.length > 0) {
const merged = { ...(existing[0].value as UserProfile), ...patch };
this.update(existing[0].id, merged, true);
} else {
this.create('user_profile', 'main', patch);
}
}
// 清理过期记忆
evictExpired(): number {
const result = this.db.prepare(
'DELETE FROM memories WHERE expires_at IS NOT NULL AND expires_at <= datetime("now")'
).run();
return result.changes;
}
// 统计
getStats(): { total: number; byType: Record<string, number> } {
const total = (this.db.prepare('SELECT COUNT(*) as c FROM memories').get() as any).c;
const byType = this.db.prepare(
'SELECT type, COUNT(*) as c FROM memories GROUP BY type'
).all().reduce((acc: any, r: any) => ({ ...acc, [r.type]: r.c }), {});
return { total, byType };
}
private rowToEntry(row: any): MemoryEntry {
return {
id: row.id,
type: row.type,
key: row.key,
value: JSON.parse(row.value),
confidence: row.confidence,
createdAt: row.created_at,
updatedAt: row.updated_at,
expiresAt: row.expires_at,
accessCount: row.access_count,
};
}
}
Memory Retriever:智能召回
不是每次都把所有记忆塞进上下文。Retriever 根据当前任务语义决定召回什么。
// memory/retriever.ts
export class MemoryRetriever {
private store: MemoryStore;
constructor(store: MemoryStore) {
this.store = store;
}
retrieveForDiagnosis(question: string, dimension?: string): RetrievedMemories {
// 1. 始终召回用户画像
const profile = this.store.getProfile();
// 2. 按维度召回历史弱点
const weakPoints = this.store.retrieve({
type: 'weak_point',
key: dimension,
limit: 3,
minConfidence: 0.6,
});
// 3. 召回相关的历史诊断摘要
const history = this.store.retrieve({
type: 'diagnosis_summary',
limit: 2,
});
return { profile, weakPoints, history };
}
formatForContext(memories: RetrievedMemories): string {
const parts: string[] = [];
if (memories.profile.targetRole) {
parts.push(`目标岗位: ${memories.profile.targetRole}`);
}
if (memories.profile.techStack?.length) {
parts.push(`技术栈: ${memories.profile.techStack.join(', ')}`);
}
if (memories.profile.weakDimensions?.length) {
parts.push(`历史弱项: ${memories.profile.weakDimensions.join(', ')}`);
}
if (memories.weakPoints.length) {
parts.push(`近期同维度弱点:\n${memories.weakPoints.map(w => `- ${(w.value as any).description}`).join('\n')}`);
}
return parts.join('\n');
}
}
interface RetrievedMemories {
profile: UserProfile;
weakPoints: MemoryEntry[];
history: MemoryEntry[];
}
Memory Triggers:什么时候写入记忆
记忆写入不能靠 Agent 自由决定(会变成垃圾桶),需要明确的触发规则。
// memory/triggers.ts
export class MemoryTriggers {
private store: MemoryStore;
constructor(store: MemoryStore) {
this.store = store;
}
afterDiagnosis(report: DiagnosisReport): void {
// 触发 1: 更新用户诊断历史
const profile = this.store.getProfile();
this.store.updateProfile({
lastDiagnosisScore: report.summary.overallScore,
totalDiagnoses: (profile.totalDiagnoses ?? 0) + 1,
scoreHistory: [
...(profile.scoreHistory ?? []),
{ date: new Date().toISOString().slice(0, 10), score: report.summary.overallScore },
].slice(-20), // 只保留最近 20 次
});
// 触发 2: 更新弱项维度
if (report.summary.topWeaknesses.length > 0) {
this.updateWeakDimensions(report.summary.topWeaknesses);
}
// 触发 3: 诊断摘要(保留关键数据点,不保留全文)
this.store.create('diagnosis_summary', `diag-${Date.now()}`, {
date: new Date().toISOString().slice(0, 10),
totalQuestions: report.summary.totalQuestions,
overallScore: report.summary.overallScore,
contentAvg: report.summary.contentAvg,
speechAvg: report.summary.speechAvg,
topWeaknesses: report.summary.topWeaknesses.slice(0, 3),
});
}
afterSingleQuestion(question: string, diagnosis: ContentDiagnosis, dimension: string): void {
// 只在低分题触发弱点记录
if (diagnosis.overallScore < 50) {
const existing = this.store.retrieve({ type: 'weak_point', key: dimension });
const alreadyKnown = existing.some(e =>
(e.value as any).description?.includes(diagnosis.keyMissing[0])
);
if (!alreadyKnown) {
this.store.create('weak_point', dimension, {
question: question.slice(0, 80),
score: diagnosis.overallScore,
description: diagnosis.keyMissing[0] ?? '回答深度不足',
dimension,
});
} else {
// 已知弱点再次出现——提升 confidence
const match = existing.find(e =>
(e.value as any).description?.includes(diagnosis.keyMissing[0])
);
if (match) this.store.update(match.id, match.value, true);
}
}
}
onUserInfo(info: Partial<UserProfile>): void {
this.store.updateProfile(info);
}
private updateWeakDimensions(weaknesses: string[]): void {
const profile = this.store.getProfile();
const current = new Set(profile.weakDimensions ?? []);
weaknesses.forEach(w => current.add(w));
// 保留最近 5 个弱项
this.store.updateProfile({
weakDimensions: [...current].slice(-5),
});
}
}
Memory 写入的克制原则
记忆系统最大的陷阱是写太多。写入不克制,召回就变成噪声。
写入规则(白名单模式):
✓ 写入:
- 用户画像变化(岗位、技术栈、经验)
- 每次诊断的关键数据点(总分、弱项、趋势)
- 反复出现的弱点(confidence > 0.6 才召回)
- 用户明确偏好("以后不要诊断语音")
✗ 不写入:
- 每道题的完整诊断结果(太大、太细)
- 对话的中间推理过程
- 工具调用的原始输出
- 一次性的临时状态
- 任何可以从知识库重新检索的信息
Context 与 Memory 的协作
flowchart TB
subgraph "Memory(持久化)"
MS[(Memory Store)]
PROFILE[User Profile]
WEAK[Weak Points]
HIST[Diagnosis History]
end
subgraph "Context(会话级)"
CM[Context Manager]
SYS[System Prompt]
TASK[Task Context]
RECENT[Recent Messages]
SUMMARY[History Summary]
end
PROFILE -->|会话开始时召回| CM
WEAK -->|按维度召回| CM
HIST -->|最近 2 次| CM
CM --> SYS
CM --> TASK
CM --> RECENT
CM --> SUMMARY
RECENT -->|诊断完成后| MS
TASK -->|低分题触发| WEAK
关键点:
- Memory → Context:会话开始时,Retriever 从 Memory 召回相关记忆注入 Context
- Context → Memory:诊断完成后,Triggers 从当前结果中提取值得长期保存的数据点写入 Memory
- 两者通过 Agent Loop 中的 Hook 协调,不直接互相调用
在 Agent Loop 中的集成
// agent/loop.ts 中相关片段
async function agentLoop(input: string, session: Session): Promise<void> {
const contextManager = session.contextManager;
const memoryStore = session.memoryStore;
const retriever = new MemoryRetriever(memoryStore);
// 1. 召回记忆注入 Context
const memories = retriever.retrieveForDiagnosis(input);
contextManager.setProfile(retriever.formatForContext(memories));
// 2. 正常 Agent Loop...
contextManager.addMessage({ role: 'user', content: input });
const window = contextManager.build();
const response = await queryEngine.query({
messages: window.messages,
systemPrompt: window.systemPrompt,
// ...
});
// 3. 检查是否需要压缩
await contextManager.autoCompact(queryEngine);
// 4. 诊断完成后触发记忆写入(通过 post-tool hook)
// 见 Hook 层实现
}
小结
- Context 分 5 层管理,每层有独立 token 预算,不把所有信息都塞给模型
- 三级压缩策略:工具输出截断 → 历史对话摘要 → 任务上下文压缩
- 不用满 200k 窗口——控制在 16k 以内,省成本、保注意力质量
- Memory 只保存稳定结论:用户画像、弱项趋势、诊断关键数据
- 写入白名单模式,不克制就退化成噪声仓库
- 弱点通过 confidence 分数管理:反复出现才提升优先级
- Context 和 Memory 通过 Hook 层协调,不直接互相调用
下一篇建议继续看: