别让 LLM 当复读机:我给文件管理系统做 AI 助手时的三个关键设计
别让 LLM 当复读机:我给文件管理系统做 AI 助手时的三个关键设计
读完这篇文章,你会了解如何在全栈系统中设计真正好用的 AI 对话能力——不是套个 ChatGPT 壳子,而是让 AI 理解上下文、精准执行查询、并以最高效的方式呈现结果。涉及 Token-aware 记忆系统、结构化直出机制、动态查询范围三个核心设计。
背景:AI 聊天不该只是"套壳"
我在做一个自托管的文件管理系统(Rust/Axum 后端 + React 前端 + TypeScript AI Sidecar),需要给它加一个 AI 助手。需求很明确:用户能用自然语言查文件、看上传记录、查审计日志,管理员还能跨用户查询。
最初的想法很朴素——接个 LLM API,把用户消息丢进去,拿到回复显示出来。但真正做下来发现,一个"好用"的 AI 助手需要解决三个核心问题:
- 对话越长越蠢 — LLM 的上下文窗口有限,历史消息塞满后要么截断要么幻觉
- 列表数据让 LLM 复读 — 用户问"我上传了哪些文件",LLM 把 20 条记录逐条复述,又慢又丑又浪费 token
- 查询范围漂移 — 管理员说"看看张三的记录",下一句"最近有什么操作",AI 就忘了还在看张三
这三个问题分别对应了三个设计:Token-aware 记忆系统、结构化直出机制、动态查询范围。下面逐个展开。
一、Token-aware 记忆系统:让对话不再"金鱼脑"
问题本质
即使是 200K token 的 Claude,一个活跃用户聊几十轮后,历史消息 + 系统提示 + 工具上下文就能把窗口撑满。传统做法是简单截断早期消息,但这会丢失关键信息——比如用户在第 3 轮说过"我是运维组的",到第 30 轮 AI 就完全不记得了。
设计思路:双层记忆 + 预算分配
我设计了一个双层记忆架构:
┌─────────────────────────────────────────┐
│ Context Window │
├──────────┬──────────┬───────────────────┤
│ System │ Memory │ History │
│ Prompt │ Blocks │ │
│ ├────┬─────┤ │
│ │Profile│Session│ │
│ (固定) │(20%) │(15%) │ (剩余) │
└──────────┴────┴─────┴───────────────────┘
- Profile 记忆(长期):用户画像,跨会话持久化。比如"这个用户是管理员,偏好简洁回复,经常查审计日志"
- Session 记忆(会话级):当前对话的上下文摘要,包括已确认的查询范围、槽位信息
关键设计是预算分配——不是把所有记忆一股脑塞进去,而是按比例分配 token 预算:
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 压缩——把"信息筛选"这个决策交给最擅长理解语义的工具:
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 轮完整对话,更早的压缩为摘要:
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,
];
}
记忆系统的核心不在于"存了多少",而在于"在有限预算内保留了什么"。简单截断是工程师偷懒,用 LLM 压缩才是把语义理解能力用在了刀刃上。
记忆的持久化与写回
记忆存储在文件系统中(每个用户一个目录),用 Markdown 格式方便调试:
memory-store/
├── user_abc/
│ ├── profile.md # 长期画像
│ └── sessions/
│ ├── sess_xxx.md # 会话 A 的记忆
│ └── sess_yyy.md # 会话 B 的记忆
写入时用 Promise 链实现串行写锁,避免并发写坏文件:
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;
}
对话结束后,系统异步提取两类记忆:会话记忆(本轮关键决策)和长期记忆(跨会话用户偏好)。两个提取过程用 Promise.allSettled 并行执行,不阻塞用户体验。长期记忆还会触发 Profile 重建——用 LLM 将新事实整合进已有画像,而不是简单追加。
二、结构化直出:让 LLM 只做它该做的事
问题本质
用户问"我最近上传了什么文件",传统做法是:查到 20 条记录 → 塞进 LLM 上下文 → LLM 生成一段文字逐条列出。
这有三个问题:慢(LLM 要生成大量文字)、丑(纯文本列表没有交互能力)、浪费(LLM 在做无脑复读,没有任何智能参与)。
让 LLM 去排版列表数据,就像让大厨去端盘子——能做,但浪费了核心能力。
设计思路:数据流一分为二
我的方案是把工具返回的数据分成两条路:
用户提问
│
▼
Intent 分析 → 工具执行 → 返回 structured (JSON) + contextText
│ │ │
│ ▼ ▼
│ 前端直接渲染列表卡片 喂给 LLM 生成引导语
│ │ │
▼ ▼ ▼
SSE 流: status → meta → delta(引导语) → done(structured + reply)
工具执行后返回两样东西:
export interface ToolResult {
toolCalls: ToolCall[];
contextText: string; // 喂给 LLM 的文本摘要(精简版)
structured?: StructuredResult; // 直接给前端渲染的完整 JSON
}
structured 的数据结构设计得足够通用,覆盖文件列表、审计日志、统计面板等所有场景:
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 中注入一条约束:
const structuredHint = params.hasStructured
? '\n\n注意:以下工具查询结果中的列表数据会由系统直接展示给用户,' +
'你不需要在回复中重复列表内容。请只输出简短的引导语或总结(1-2句话),' +
'不要逐条列出数据。'
: '';
这样 LLM 的输出从"找到以下 20 个文件:1. xxx.pdf 2. yyy.doc…“变成了"找到了 20 个文件,最近一次上传是昨天的报告 📄”——简短、有信息量、不复读。
SSE 流式传输:分阶段推送
前端通过 SSE 接收分阶段的事件流:
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 全文生成) | 结构化直出 |
|---|---|---|
| 响应时间 | 3-8s(等 LLM 生成长文本) | 1-2s(LLM 只生成 1-2 句) |
| 输出 token | 500-2000 | 30-80 |
| 交互能力 | 纯文本,无法点击 | 卡片可点击、可操作 |
| 数据准确性 | LLM 可能遗漏/编造 | 100% 来自工具查询结果 |
三、动态查询范围:让 AI 记住"你在看谁"
问题本质
管理员的典型对话:
用户:看看张三最近的上传记录
AI:(查询张三的上传记录,正常返回)
用户:他下载了什么?
AI:(??? "他"是谁?查全部人还是查张三?)
如果每轮都要用户重复"张三的",体验很差。但如果 AI 盲目继承上一轮的目标,又会出现范围"粘住"的问题——用户已经换了话题,AI 还在查张三。
设计思路:三层判断 + 用户可控锁定
第一层:规则引擎快速判断(零延迟)
用正则匹配明确的指代词:
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 时,系统先尝试 resolveExplicitTargetScope(只看当前消息,不参考历史),只有它也返回 inherit 时才调用带历史的 resolveTargetScope。这个分层避免了"历史污染当前意图"的问题:
export async function resolveTargetScope(profile, input) {
const prompt = [
'判断当前问题问的是当前登录账号本人,还是另一个明确账号,还是沿用之前范围。',
`最近对话历史:\n${historyText}`,
`当前用户消息: ${input.userMessage}`,
'输出格式:{"target_scope":"self|other|inherit","target_username":"..."}',
].join('\n');
}
第三层:用户主动锁定/清除(最终控制权)
这是最关键的设计——查询范围不是系统单方面推断的,用户拥有显式控制权:
function detectLockCommand(message: string, role: string) {
// "清除范围" / "取消筛选" → 清除所有锁定
if (/^(清除|取消|重置).*(范围|筛选|过滤|上下文)/.test(trimmed)) {
return { kind: 'clear', reply: '好的,已清除之前锁定的查询范围...' };
}
// "接下来都看张三" / "锁定到最近7天" → 设置锁定
const targetMatch = trimmed.match(/^(接下来都看|后面都看|只看|锁定到)(.+)$/);
}
锁定支持多维度组合:目标用户 + 时间范围 + 操作类型 + 目录范围,比如"接下来都看 zhangsan 最近 3 天的上传"。锁定后所有查询自动带上这些过滤条件,直到用户说"清除范围"或开始新会话。
查询范围的设计哲学:AI 可以推断,但用户拥有最终控制权。推断是便利,锁定是确定性——两者缺一不可。
范围的传递链路
用户消息
│
├─ detectLockCommand() → 是锁定/清除指令?直接响应,更新 session state
│
├─ detectTargetScopeByRule() → 规则能判断?用规则结果
│
├─ resolveExplicitTargetScope() → 当前消息有明确目标?用它
│
└─ resolveTargetScope() → 结合历史推断
│
▼
最终 target_scope 注入 intent analysis → 工具执行时自动应用
工具执行时,pickTargetUsername 按优先级取值:
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; // 已解析槽位
}
前端配合:范围状态可视化
前端从 session memory 中读取当前锁定状态,显示为可见标签。用户能清楚看到"当前范围:张三 + 最近 7 天",不会产生"AI 在查谁"的困惑。结构化结果中的 reuse_query_scope action 还允许用户一键复用某条结果的范围——点击某个用户名,自动锁定到该用户。
整体架构一览
┌──────────────┐ 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 阈值,低置信度时主动追问而不是瞎猜。
教训:LLM 的判断需要"刹车机制"。不确定时说"我不确定你想查什么",远好过自信地查错东西。
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 擅长理解语义、生成自然语言、做模糊判断,但它不擅长精确数据展示、状态管理、规则执行。
我的核心 takeaway 是:好的 AI 系统设计,本质上是一个"分工"问题。
- 理解用户意图 → LLM(但先过规则引擎)
- 执行精确查询 → 工具系统(直接调 API)
- 展示结构化数据 → 前端渲染(不经过 LLM)
- 管理对话状态 → 显式状态机(不靠 LLM 记忆)
- 生成自然语言 → LLM(但只生成它该生成的部分)
每个环节交给最擅长的组件,LLM 才能从"什么都干但什么都干不好"变成"只干自己擅长的事,干得很好"。
如果这篇文章对你有帮助,欢迎点赞 👍 收藏 ⭐ 关注,后续会继续分享自托管系统和 AI 工程化的实践经验。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)