别让 LLM 当复读机:我给文件管理系统做 AI 助手时的三个关键设计

为文件管理系统设计一个真正好用的 AI 助手:记忆、结构化直出与动态查询范围

读完这篇文章,你会了解如何在一个全栈系统中设计 AI 对话能力------不是套个 ChatGPT 壳子,而是让 AI 真正理解上下文、精准执行查询、并以最高效的方式呈现结果。

背景:AI 聊天不该只是"套壳"

我在做一个自托管的文件管理系统 File Relay(Rust 后端 + React 前端 + AI Sidecar),需要给它加一个 AI 助手。需求很明确:用户能用自然语言查文件、看上传记录、查审计日志,管理员还能跨用户查询。

最初的想法很朴素------接个 LLM API,把用户消息丢进去,拿到回复显示出来。但真正做下来发现,一个"好用"的 AI 助手需要解决三个核心问题:

  1. 对话越长越蠢 --- LLM 的上下文窗口是有限的,历史消息塞满后要么截断要么幻觉
  2. 列表数据让 LLM 复读 --- 用户问"我上传了哪些文件",LLM 把 20 条记录逐条复述一遍,又慢又丑
  3. 查询范围漂移 --- 管理员说"看看张三的记录",下一句"最近有什么操作",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 做压缩而不是简单截断,本质上是把"信息筛选"这个决策交给了最擅长理解语义的工具。

记忆的写回:异步双写

对话结束后,系统会异步提取两类记忆:

  1. 会话记忆:从本轮对话中提取关键决策和确认信息
  2. 长期记忆:识别值得跨会话保留的用户偏好和事实

这两个提取过程和主对话流并行执行(Promise.allSettled),不阻塞用户体验。长期记忆还会触发 Profile 重建------用 LLM 将新事实整合进已有的用户画像中,而不是简单追加。

二、结构化直出:让 LLM 不再当复读机

问题本质

用户问"我最近上传了什么文件",传统做法是:

  1. 调用工具查到 20 条记录
  2. 把记录塞进 LLM 上下文
  3. 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 获取数据。这个设计的好处:

  1. 后端零侵入 --- Rust 后端不需要知道 AI 的存在,保持纯粹的文件管理职责
  2. Provider 可切换 --- 支持 OpenAI / Claude / DeepSeek,通过配置切换
  3. 独立部署 --- 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 工程化的实践经验。

相关推荐
摄影图1 小时前
AI设计实用图片素材 适配多元创作推广需求
人工智能·科技·智能手机·aigc·贴图
HS_Tiger1 小时前
【个人对AI技术的观点验证】
人工智能
小陶来咯1 小时前
AI Agent 设计模式:ReAct 深度解析
人工智能·react.js·设计模式
Muyuan19981 小时前
31.Cursor 初体验:用 AI Agent 给 PaperPilot 做一次最小工程重构
人工智能·python·重构·django·fastapi·faiss
阿聪谈架构1 小时前
第11章:结构化输出与数据提取 —— 让 AI 直接返回你想要的数据格式
人工智能·后端
OpenBayes贝式计算1 小时前
外语、方言、少数民族语言全覆盖:Hy-MT1.5 支持 1056 个翻译方向;MIT 联合发布 MathNet:涵盖 2.7 万道奥数真题的多模态数学推理基准
人工智能
OpenCSG2 小时前
CSGHub v2.1.0开源版本更新
人工智能
沪漂阿龙2 小时前
Dify 面试题详解:开源 LLM 应用开发平台、RAG 知识库、Workflow 工作流、Agent 智能体一文讲透
人工智能·架构
移动云开发者联盟2 小时前
存智赋能 共筑AI存储新生态,移动云聚力技术创新夯实AI数据基石
大数据·人工智能