别让 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 预算constbudgetgetModelLimit(profile);// 模型上限 × 0.85 安全系数constfixedCostsystemTokensuserMsgTokens100;letremainingbudget-fixedCost;// Profile 占 20%Session 占 15%剩余给历史消息constprofileBudgetMath.floor(remaining*0.20);constsessionBudgetMath.floor(remaining*0.15);当记忆内容超出预算时不是截断而是用 LLM 压缩——把信息筛选这个决策交给最擅长理解语义的工具exportasyncfunctioncompressText(text:string,targetTokens:number,profile:RuntimeProviderProfile,):Promisestring{constmodeltoCompressModel(profile);// gpt-4.1-mini / deepseek-chatconstresponseawaitgenerateProviderReply(model,{systemPrompt:你是文本压缩器。将输入内容精简为摘要保留关键信息...,history:[],userMessage:将以下内容压缩到约${targetTokens}token 以内...,});returnresponse.text;}历史消息的压缩更精细——保留最近 4 轮完整对话更早的压缩为摘要exportasyncfunctioncompressHistory(history,targetTokens,profile){constrecentCountMath.min(4,history.length);constrecenthistory.slice(-recentCount);// 最近 4 轮保持原样constolderhistory.slice(0,-recentCount);// 更早的压缩为摘要constsummaryawaitgenerateProviderReply(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 链实现串行写锁避免并发写坏文件functionwithLock(key:string,fn:()Promisevoid):Promisevoid{constprevwriteLocks.get(key)||Promise.resolve();constnextprev.then(fn,fn);writeLocks.set(key,next);next.finally((){if(writeLocks.get(key)next)writeLocks.delete(key);});returnnext;}对话结束后系统异步提取两类记忆会话记忆本轮关键决策和长期记忆跨会话用户偏好。两个提取过程用Promise.allSettled并行执行不阻塞用户体验。长期记忆还会触发 Profile 重建——用 LLM 将新事实整合进已有画像而不是简单追加。二、结构化直出让 LLM 只做它该做的事问题本质用户问我最近上传了什么文件传统做法是查到 20 条记录 → 塞进 LLM 上下文 → LLM 生成一段文字逐条列出。这有三个问题慢LLM 要生成大量文字、丑纯文本列表没有交互能力、浪费LLM 在做无脑复读没有任何智能参与。让 LLM 去排版列表数据就像让大厨去端盘子——能做但浪费了核心能力。设计思路数据流一分为二我的方案是把工具返回的数据分成两条路用户提问 │ ▼ Intent 分析 → 工具执行 → 返回 structured (JSON) contextText │ │ │ │ ▼ ▼ │ 前端直接渲染列表卡片 喂给 LLM 生成引导语 │ │ │ ▼ ▼ ▼ SSE 流: status → meta → delta(引导语) → done(structured reply)工具执行后返回两样东西exportinterfaceToolResult{toolCalls:ToolCall[];contextText:string;// 喂给 LLM 的文本摘要精简版structured?:StructuredResult;// 直接给前端渲染的完整 JSON}structured的数据结构设计得足够通用覆盖文件列表、审计日志、统计面板等所有场景exportinterfaceStructuredResult{type:list|stats;title:string;items:StructuredItem[];total?:number;meta?:StructuredField[];// 汇总信息}exportinterfaceStructuredItem{title:string;subtitle?:string;fields:StructuredField[];// 每条记录的字段actions?:StructuredAction[];// 可交互操作打开文件、锁定范围等}让 LLM 只负责说人话当存在 structured 数据时system prompt 中注入一条约束conststructuredHintparams.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-2sLLM 只生成 1-2 句输出 token500-200030-80交互能力纯文本无法点击卡片可点击、可操作数据准确性LLM 可能遗漏/编造100% 来自工具查询结果三、动态查询范围让 AI 记住你在看谁问题本质管理员的典型对话用户看看张三最近的上传记录 AI查询张三的上传记录正常返回 用户他下载了什么 AI 他是谁查全部人还是查张三如果每轮都要用户重复张三的体验很差。但如果 AI 盲目继承上一轮的目标又会出现范围粘住的问题——用户已经换了话题AI 还在查张三。设计思路三层判断 用户可控锁定第一层规则引擎快速判断零延迟用正则匹配明确的指代词functiondetectTargetScopeByRule(message:string):TargetScopeResult{// 账号 zhangsan / zhangsan 账号 → 明确指向他人constaccountMatchtrimmed.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};// 无法判断 → 交给 LLMreturn{target_scope:inherit};}第二层LLM 语义判断分级推断当规则引擎返回inherit时系统先尝试resolveExplicitTargetScope只看当前消息不参考历史只有它也返回inherit时才调用带历史的resolveTargetScope。这个分层避免了历史污染当前意图的问题exportasyncfunctionresolveTargetScope(profile,input){constprompt[判断当前问题问的是当前登录账号本人还是另一个明确账号还是沿用之前范围。,最近对话历史:\n${historyText},当前用户消息:${input.userMessage},输出格式{target_scope:self|other|inherit,target_username:...},].join(\n);}第三层用户主动锁定/清除最终控制权这是最关键的设计——查询范围不是系统单方面推断的用户拥有显式控制权functiondetectLockCommand(message:string,role:string){// 清除范围 / 取消筛选 → 清除所有锁定if(/^(清除|取消|重置).*(范围|筛选|过滤|上下文)/.test(trimmed)){return{kind:clear,reply:好的已清除之前锁定的查询范围...};}// 接下来都看张三 / 锁定到最近7天 → 设置锁定consttargetMatchtrimmed.match(/^(接下来都看|后面都看|只看|锁定到)(.)$/);}锁定支持多维度组合目标用户 时间范围 操作类型 目录范围比如接下来都看 zhangsan 最近 3 天的上传。锁定后所有查询自动带上这些过滤条件直到用户说清除范围或开始新会话。查询范围的设计哲学AI 可以推断但用户拥有最终控制权。推断是便利锁定是确定性——两者缺一不可。范围的传递链路用户消息 │ ├─ detectLockCommand() → 是锁定/清除指令直接响应更新 session state │ ├─ detectTargetScopeByRule() → 规则能判断用规则结果 │ ├─ resolveExplicitTargetScope() → 当前消息有明确目标用它 │ └─ resolveTargetScope() → 结合历史推断 │ ▼ 最终 target_scope 注入 intent analysis → 工具执行时自动应用工具执行时pickTargetUsername按优先级取值functionpickTargetUsername(explicitValue,explicitScope,state){if(scopeself)returnundefined;// 明确说我的if(explicit)returnexplicit;// 当前消息明确指定returnstate?.locked_filters?.target_username// 锁定范围||state?.resolved_slots?.target_username;// 已解析槽位}前端配合范围状态可视化前端从 session memory 中读取当前锁定状态显示为可见标签。用户能清楚看到当前范围张三 最近 7 天不会产生AI 在查谁的困惑。结构化结果中的reuse_query_scopeaction 还允许用户一键复用某条结果的范围——点击某个用户名自动锁定到该用户。整体架构一览┌──────────────┐ 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 工程化的实践经验。