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

读完这篇文章,你会了解如何在全栈系统中设计真正好用的 AI 对话能力——不是套个 ChatGPT 壳子,而是让 AI 理解上下文、精准执行查询、并以最高效的方式呈现结果。涉及 Token-aware 记忆系统、结构化直出机制、动态查询范围三个核心设计。

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

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

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

  1. 对话越长越蠢 — LLM 的上下文窗口有限,历史消息塞满后要么截断要么幻觉
  2. 列表数据让 LLM 复读 — 用户问"我上传了哪些文件",LLM 把 20 条记录逐条复述,又慢又丑又浪费 token
  3. 查询范围漂移 — 管理员说"看看张三的记录",下一句"最近有什么操作",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 获取数据。这个设计带来三个好处:

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

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐