为文件管理系统设计一个真正好用的 AI 助手:记忆、结构化直出与动态查询范围
读完这篇文章,你会了解如何在一个全栈系统中设计 AI 对话能力------不是套个 ChatGPT 壳子,而是让 AI 真正理解上下文、精准执行查询、并以最高效的方式呈现结果。
背景:AI 聊天不该只是"套壳"
我在做一个自托管的文件管理系统 File Relay(Rust 后端 + React 前端 + AI Sidecar),需要给它加一个 AI 助手。需求很明确:用户能用自然语言查文件、看上传记录、查审计日志,管理员还能跨用户查询。
最初的想法很朴素------接个 LLM API,把用户消息丢进去,拿到回复显示出来。但真正做下来发现,一个"好用"的 AI 助手需要解决三个核心问题:
- 对话越长越蠢 --- LLM 的上下文窗口是有限的,历史消息塞满后要么截断要么幻觉
- 列表数据让 LLM 复读 --- 用户问"我上传了哪些文件",LLM 把 20 条记录逐条复述一遍,又慢又丑
- 查询范围漂移 --- 管理员说"看看张三的记录",下一句"最近有什么操作",AI 就忘了还在看张三
这三个问题分别对应了三个设计:Token-aware 记忆系统、结构化直出机制、动态查询范围。下面逐个展开。
一、Token-aware 记忆系统:让 AI 不再"金鱼脑"
问题本质
LLM 的上下文窗口不是无限的。即使是 200K token 的 Claude,一个活跃用户聊几十轮后,历史消息 + 系统提示 + 工具上下文就能把窗口撑满。传统做法是简单截断早期消息,但这会丢失关键信息------比如用户在第 3 轮说过"我是运维组的",到第 30 轮 AI 就完全不记得了。
设计思路:双层记忆 + 预算分配
我设计了一个双层记忆架构:
scss
┌─────────────────────────────────────────┐
│ Context Window │
├──────────┬──────────┬───────────────────┤
│ System │ Memory │ History │
│ Prompt │ Blocks │ │
│ ├────┬─────┤ │
│ │Profile│Session│ │
│ (固定) │(20%) │(15%) │ (剩余) │
└──────────┴────┴─────┴───────────────────┘
- Profile 记忆(长期):用户画像,跨会话持久化。比如"这个用户是管理员,偏好简洁回复,经常查审计日志"
- Session 记忆(会话级):当前对话的上下文摘要,包括已确认的查询范围、槽位信息
关键设计是预算分配------不是把所有记忆一股脑塞进去,而是按比例分配 token 预算:
typescript
// 核心预算分配逻辑
const budget = getModelLimit(profile); // 模型上限 × 0.85 安全系数
const fixedCost = systemTokens + userMsgTokens + 100;
let remaining = budget - fixedCost;
// Profile 占 20%,Session 占 15%,剩余给历史消息
const profileBudget = Math.floor(remaining * 0.20);
const sessionBudget = Math.floor(remaining * 0.15);
当记忆内容超出预算时,不是截断,而是用 LLM 压缩:
typescript
export async function compressText(
text: string,
targetTokens: number,
profile: RuntimeProviderProfile,
): Promise<string> {
// 用小模型做压缩,保留关键信息,删除冗余
const model = toCompressModel(profile); // gpt-4.1-mini / deepseek-chat
const response = await generateProviderReply(model, {
systemPrompt: '你是文本压缩器。将输入内容精简为摘要,保留关键信息...',
history: [],
userMessage: `将以下内容压缩到约 ${targetTokens} token 以内...`,
});
return response.text;
}
历史消息的压缩更精细------保留最近 4 轮完整对话,更早的对话压缩为摘要:
typescript
export async function compressHistory(history, targetTokens, profile) {
const recentCount = Math.min(4, history.length);
const recent = history.slice(-recentCount); // 最近 4 轮保持原样
const older = history.slice(0, -recentCount); // 更早的压缩为摘要
const summary = await generateProviderReply(model, {
systemPrompt: '你是对话摘要器...',
userMessage: `将以下对话历史压缩为摘要...`,
});
return [
{ role: 'user', content: `[历史对话摘要] ${summary.text}` },
...recent,
];
}
记忆的持久化:文件系统 + 写锁
记忆存储在文件系统中(每个用户一个目录),用 Markdown 格式方便调试:
bash
memory-store/
├── user_abc/
│ ├── profile.md # 长期画像
│ └── sessions/
│ ├── sess_xxx.md # 会话 A 的记忆
│ └── sess_yyy.md # 会话 B 的记忆
写入时用 Promise 链实现串行写锁,避免并发写坏文件:
typescript
function withLock(key: string, fn: () => Promise<void>): Promise<void> {
const prev = writeLocks.get(key) || Promise.resolve();
const next = prev.then(fn, fn);
writeLocks.set(key, next);
next.finally(() => {
if (writeLocks.get(key) === next) writeLocks.delete(key);
});
return next;
}
我的核心观点是:记忆系统的设计不在于"存了多少",而在于"在有限预算内保留了什么"。 用 LLM 做压缩而不是简单截断,本质上是把"信息筛选"这个决策交给了最擅长理解语义的工具。
记忆的写回:异步双写
对话结束后,系统会异步提取两类记忆:
- 会话记忆:从本轮对话中提取关键决策和确认信息
- 长期记忆:识别值得跨会话保留的用户偏好和事实
这两个提取过程和主对话流并行执行(Promise.allSettled),不阻塞用户体验。长期记忆还会触发 Profile 重建------用 LLM 将新事实整合进已有的用户画像中,而不是简单追加。
二、结构化直出:让 LLM 不再当复读机
问题本质
用户问"我最近上传了什么文件",传统做法是:
- 调用工具查到 20 条记录
- 把记录塞进 LLM 上下文
- LLM 生成一段文字,逐条列出文件名、大小、时间...
这有三个问题:慢 (LLM 要生成大量文字)、丑 (纯文本列表没有交互能力)、浪费(LLM 在做无脑复读,没有任何智能参与)。
设计思路:工具返回结构化数据,前端直接渲染
我的方案是把数据流分成两条路:
scss
用户提问
│
▼
Intent 分析 → 工具执行 → 返回 structured (JSON) + contextText
│ │ │
│ ▼ ▼
│ 前端直接渲染列表卡片 喂给 LLM 生成引导语
│ │ │
▼ ▼ ▼
SSE 流: status → meta → delta(引导语) → done(structured + reply)
工具执行后返回两样东西:
typescript
export interface ToolResult {
toolCalls: ToolCall[];
contextText: string; // 喂给 LLM 的文本摘要
structured?: StructuredResult; // 直接给前端渲染的 JSON
}
structured 的数据结构设计得足够通用,能覆盖文件列表、审计日志、统计面板等所有场景:
typescript
export interface StructuredResult {
type: 'list' | 'stats';
title: string;
items: StructuredItem[];
total?: number;
meta?: StructuredField[]; // 汇总信息
}
export interface StructuredItem {
title: string;
subtitle?: string;
fields: StructuredField[]; // 每条记录的字段
actions?: StructuredAction[]; // 可交互操作(打开文件、锁定范围等)
}
LLM 只负责"说人话"
当存在 structured 数据时,系统会在 system prompt 中注入一条提示:
typescript
const structuredHint = params.hasStructured
? '\n\n注意:以下工具查询结果中的列表数据会由系统直接展示给用户,' +
'你不需要在回复中重复列表内容。请只输出简短的引导语或总结(1-2句话),' +
'不要逐条列出数据。'
: '';
这样 LLM 的输出从"找到以下 20 个文件:1. xxx.pdf 2. yyy.doc..."变成了"找到了 20 个文件,最近一次上传是昨天的报告 📄"------简短、有信息量、不复读。
SSE 流式传输:分阶段推送
前端通过 SSE 接收分阶段的事件流:
typescript
// 后端推送事件
writeEvent(res, { type: 'status', stage: 'intent', message: '正在理解你的问题...' });
writeEvent(res, { type: 'status', stage: 'tool', message: '正在查询相关记录...' });
writeEvent(res, { type: 'meta', provider, model, tool_calls });
writeEvent(res, { type: 'delta', delta: '找到了...' }); // LLM 流式输出
writeEvent(res, { type: 'done', reply, structured }); // 最终结果
前端收到 done 事件后,如果包含 structured,就直接渲染为卡片列表,每张卡片带有可点击的 action(打开文件、跳转目录、复用查询范围)。
结构化直出的本质是一个分工问题:数据展示交给前端(它擅长),语义理解交给 LLM(它擅长)。 让 LLM 去排版列表,就像让厨师去端盘子------能做,但浪费了核心能力。
效果对比
| 指标 | 传统方式(LLM 全文生成) | 结构化直出 |
|---|---|---|
| 响应时间 | 3-8s(等 LLM 生成长文本) | 1-2s(LLM 只生成 1-2 句) |
| 输出 token | 500-2000 | 30-80 |
| 交互能力 | 纯文本,无法点击 | 卡片可点击跳转 |
| 数据准确性 | LLM 可能遗漏/编造 | 100% 来自工具查询 |
三、动态查询范围:让 AI 记住"你在看谁"
问题本质
管理员的典型对话:
arduino
用户:看看张三最近的上传记录
AI:(查询张三的上传记录)
用户:他下载了什么?
AI:(??? "他"是谁?查谁的?)
如果每轮都要用户重复"张三的",体验很差。但如果 AI 盲目继承上一轮的目标,又会出现范围"粘住"的问题------用户已经换了话题,AI 还在查张三。
设计思路:规则引擎 + LLM 双重判断 + 用户可控锁定
我的方案分三层:
第一层:规则引擎快速判断
用正则匹配明确的指代词,零延迟:
typescript
function detectTargetScopeByRule(message: string): TargetScopeResult {
// "账号 zhangsan" / "zhangsan 账号" → 明确指向他人
const accountMatch = trimmed.match(/(?:账号|用户)\s*([A-Za-z0-9._-]{3,})/);
if (candidate) return { target_scope: 'other', target_username: candidate };
// "我的" / "帮我" / "我自己" → 明确指向自己
if (trimmed.includes('我的') || trimmed.startsWith('我'))
return { target_scope: 'self' };
// 无法判断 → 交给 LLM
return { target_scope: 'inherit' };
}
第二层:LLM 语义判断
当规则引擎返回 inherit 时,调用 LLM 结合对话历史判断:
typescript
export async function resolveTargetScope(profile, input) {
const prompt = [
'判断当前问题问的是当前登录账号本人,还是另一个明确账号,还是沿用之前范围。',
`最近对话历史:\n${historyText}`,
`当前用户消息: ${input.userMessage}`,
'输出格式:{"target_scope":"self|other|inherit","target_username":"..."}',
].join('\n');
// ...
}
注意这里有两个版本:resolveExplicitTargetScope(只看当前消息,不参考历史)和 resolveTargetScope(结合历史判断)。系统先尝试 explicit 版本,只有当它也返回 inherit 时才用带历史的版本。这个分层避免了"历史污染当前意图"的问题。
第三层:用户主动锁定/清除
这是最关键的设计------查询范围不是系统单方面推断的,用户可以主动控制:
typescript
function detectLockCommand(message: string, role: string) {
// "清除范围" / "取消筛选" → 清除所有锁定
if (/^(清除|取消|重置).*(范围|筛选|过滤|上下文)/.test(trimmed)) {
return { kind: 'clear', reply: '好的,已清除之前锁定的查询范围...' };
}
// "接下来都看张三" / "锁定到最近7天" → 设置锁定
const targetMatch = trimmed.match(/^(接下来都看|后面都看|只看|锁定到)(.+)$/);
// 解析出 target_username / start_time / action / folder_path 等
}
锁定支持多个维度的组合:
- 目标用户:
接下来都看 zhangsan - 时间范围:
锁定到最近 7 天 - 操作类型:
只看上传/只看下载 - 目录范围:
锁定到目录 /projects - 组合:
接下来都看 zhangsan 最近 3 天的上传
锁定后,后续所有查询都会自动带上这些过滤条件,直到用户说"清除范围"或开始新会话。
范围的传递链路
scss
用户消息
│
├─ detectLockCommand() → 是锁定/清除指令?直接响应,更新 session state
│
├─ detectTargetScopeByRule() → 规则能判断?用规则结果
│
├─ resolveExplicitTargetScope() → 当前消息有明确目标?用它
│
└─ resolveTargetScope() → 结合历史推断
│
▼
最终 target_scope 注入 intent analysis → 工具执行时自动应用
工具执行时,pickTargetUsername 函数按优先级取值:
typescript
function pickTargetUsername(explicitValue, explicitScope, state) {
if (scope === 'self') return undefined; // 明确说"我的"
if (explicit) return explicit; // 当前消息明确指定
// 否则从锁定范围或已解析槽位中继承
return state?.locked_filters?.target_username
|| state?.resolved_slots?.target_username;
}
查询范围的设计哲学是:AI 可以推断,但用户拥有最终控制权。 "接下来都看张三"是用户的明确指令,系统必须遵守;但这不是永久锁死------用户随时可以清除、覆盖、或开新会话重置。
前端的配合:范围状态可视化
前端会从 session memory 中读取当前锁定状态,显示为一个可见的标签:
typescript
export interface SessionMemoryState {
locked_filters?: {
target_username?: string | null;
start_time?: string | null;
end_time?: string | null;
action?: string | null;
folder_id?: string | null;
folder_path?: string | null;
} | null;
}
用户能清楚看到"当前范围:张三 + 最近 7 天",不会产生"AI 在查谁"的困惑。结构化结果中的 reuse_query_scope action 还允许用户一键复用某条结果的范围------比如点击某个用户名,自动锁定到该用户。
整体架构:三个服务如何协作
scss
┌──────────────┐ SSE ┌──────────────┐ REST ┌──────────────┐
│ React 前端 │◄────────────►│ AI Sidecar │◄──────────►│ Rust 后端 │
│ (Vite) │ │ (Express) │ │ (Axum) │
│ │ │ │ │ │
│ - ChatDrawer │ │ - Intent分析 │ │ - 文件 CRUD │
│ - 结构化渲染 │ │ - 记忆管理 │ │ - 审计日志 │
│ - 范围标签 │ │ - 工具执行 │ │ - 权限系统 │
│ │ │ - SSE 流 │ │ - WebDAV │
└──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────┐
│ LLM Provider │
│ (OpenAI/Claude│
│ /DeepSeek) │
└──────────────┘
AI Sidecar 是独立进程,通过 REST 调用后端 API 获取数据。这个设计的好处:
- 后端零侵入 --- Rust 后端不需要知道 AI 的存在,保持纯粹的文件管理职责
- Provider 可切换 --- 支持 OpenAI / Claude / DeepSeek,通过配置切换
- 独立部署 --- AI 功能可以关闭,不影响核心文件管理
踩坑与经验
1. Intent 分析的"过度自信"问题
早期版本中,LLM 做 intent 分析时经常"过度自信"------用户随便聊两句,它就判定为某个具体 intent 并执行工具。解决方案是加入 needs_clarification 字段和 confidence 阈值,低置信度时主动追问而不是瞎猜。
2. 结构化直出的边界
不是所有回复都适合结构化。当用户问"这个文件是干什么的"或"帮我解释一下权限规则"时,应该走纯文本回复。判断标准是:工具查询返回的是列表数据时用结构化,返回的是需要解释的信息时走 LLM 生成。
3. 记忆压缩的成本
用 LLM 压缩记忆意味着每次对话可能多 1-2 次 API 调用。我的做法是用小模型(gpt-4.1-mini / deepseek-chat)做压缩,成本约为主模型的 1/10,延迟增加 200-500ms,可接受。
4. 查询范围的"粘性"平衡
最初设计是范围一旦推断出来就自动继承。但用户反馈"AI 老是查错人"------因为 LLM 的 inherit 判断不够准确。最终改为:只有用户主动锁定的范围才会强继承,LLM 推断的范围只在当前轮生效。这个区分很重要。
写在最后
做 AI 功能最容易掉进的坑是"什么都让 LLM 干"。LLM 擅长理解语义、生成自然语言、做模糊判断,但它不擅长精确数据展示、状态管理、规则执行。好的 AI 系统设计是把每个环节交给最擅长的组件:
- 理解用户意图 → LLM(但先过规则引擎)
- 执行精确查询 → 工具系统(直接调 API)
- 展示结构化数据 → 前端渲染(不经过 LLM)
- 管理对话状态 → 显式状态机(不靠 LLM 记忆)
- 生成自然语言 → LLM(但只生成它该生成的部分)
如果这篇文章对你有帮助,欢迎点赞 👍 收藏 ⭐ 关注,后续会继续分享自托管系统和 AI 工程化的实践经验。