用户只说了一句话:

把我今天关于发布计划的笔记,以及还没完成的相关待办,整理成一篇复盘。

这类需求看起来很适合交给 AI:理解一句自然语言,找到个人工作区中的资料,再生成一篇结构完整的笔记。

第一版链路通常会写成:

判断用户要不要生成笔记
        ↓
让 Planner 决定查询哪些资料
        ↓
执行查询
        ↓
把材料交给模型生成草稿

一共需要 3 次模型调用。

我原本也觉得这很合理:每个模型只做一件事,职责清楚,复杂任务就多规划一轮。

真正接入真实笔记、书签、文件和待办后,我却发现一个反直觉的问题:

模型调用越多,不一定理解得越充分,反而可能把同一个材料范围解释两遍,并在第二遍悄悄改掉。

于是我把链路改成了 2 次:

第 1 次:同时判断任务类型,并输出结构化材料范围
        ↓
服务端确定性执行只读查询
        ↓
第 2 次:根据权威材料生成待确认草稿

减少一轮后,延迟和 Token 自然下降;更重要的是,材料范围只被模型解释一次,查询行为也变得可测试。

本文就从这句“总结我今天的笔记”开始,拆解一套可以复用的 AI 知识整理链路。

在这里插入图片描述

一、问题不在生成,而在“今天的笔记”到底是什么

当用户说:

总结我今天的笔记。

人很容易觉得查询条件已经很明确了。

但后端真正需要的是:

{
  "resourceType": "note",
  "timeRange": "今天"
}

如果用户改成:

把我今天关于发布计划的笔记,以及还没完成的相关待办,整理成一篇复盘。

材料范围就至少包含:

[
  {
    "resourceType": "note",
    "keyword": "发布计划",
    "timeRange": "今天"
  },
  {
    "resourceType": "todo",
    "keyword": "发布计划",
    "todoStatus": "pending"
  }
]

这已经不是普通关键词搜索,而是一组包含资源类型、主题、时间和状态的查询计划。

更麻烦的是,同一句话里还混合了两个不同目标:

  1. 读取工作区中的真实材料;
  2. 生成一篇可保存的新笔记。

如果直接把整句交给一个通用 Agent,它既要猜材料范围,又要选择工具,还要决定什么时候写数据库。表面上只发了一条消息,内部却把检索、写作和写入权限全部绑在一起。

二、为什么 3 次调用反而容易范围漂移

旧链路中的 3 次模型调用分别是:

调用 1:这是问答,还是要产出一篇笔记?
调用 2:为了完成任务,应该查询哪些资料?
调用 3:根据查询结果生成正文

单看每一步都合理,问题出在调用 1 和调用 2 都会读取原始用户消息,并各自解释一次。

例如第一次分类得到:

需要查询“今天关于发布计划的笔记”和“未完成待办”

第二次 Planner 可能重新理解成:

查询最近几天的全部笔记,再查询最新待办

这种变化通常不会导致接口报错。查询仍然成功,模型也能写出一篇语言流畅的复盘。

真正危险的地方是:结果看起来合理,但材料范围已经不再是用户要求的范围。

多一轮推理还会带来其他成本:

  • 多一次 Provider 延迟;
  • 多一份系统 Prompt;
  • 多一轮上下文序列化;
  • 多一次 Tool Schema 选择;
  • 多一组失败重试与追踪状态;
  • Planner 输出无法完整映射时,还需要额外澄清。

因此,优化目标不能只写成“减少模型调用次数”,而应该是:

让材料范围只经过一次非确定性解释,之后全部由确定性代码执行。

三、把任务判断和材料范围放进同一个分类协议

新的第一轮模型调用不再只返回一个布尔值,而是通过闭合 Tool Schema 一次输出四类信息:

{
  "producesNote": true,
  "otherMutations": false,
  "needsWorkspaceRetrieval": true,
  "workspaceQueries": [
    {
      "resourceType": "note",
      "keyword": "发布计划",
      "timeRange": "今天"
    },
    {
      "resourceType": "todo",
      "keyword": "发布计划",
      "todoStatus": "pending"
    }
  ]
}

producesNote

用户是否真的要求产出一篇可以保存的笔记。

下面两句看起来相近,但结果不同:

总结这些材料,告诉我重点。
把这些材料总结成一篇新的笔记。

第一句是只读问答,第二句才进入笔记草稿链路。

otherMutations

用户是否同时要求了其他写操作。

例如:

生成一篇复盘,再创建一个明天提醒我查看的待办。

这不是单一笔记任务,应该交回通用 Planner,不能让专用草稿链路悄悄忽略后半句。

needsWorkspaceRetrieval

已有显式选择的材料是否足够。如果用户说的是“我今天的笔记”而没有选中具体资源,就必须先查询工作区。

workspaceQueries

把材料范围转换成 1~4 个结构化只读查询,支持的资源类型包括:

note / bookmark / file / todo

这一步的价值在于:让分类结果不仅说明“需要查询”,还完整说明“查什么”。

如果只返回 needsWorkspaceRetrieval: true,后面还要再调用一次 Planner 理解材料范围,链路又多了一层。

在这里插入图片描述

四、分类器只描述范围,服务端负责真正查询

结构化输出不能直接成为数据库查询。

服务端还要把它映射到已经存在、已经做过权限校验的只读工具:

function buildQueryCalls(queries) {
  return queries.flatMap((query) => {
    switch (query.resourceType) {
      case 'note':
        return [{
          toolName: 'query_notes',
          args: {
            keyword: query.keyword,
            timeRange: query.timeRange,
            limit: 50,
          },
        }];

      case 'bookmark':
        return [{
          toolName: 'query_bookmarks',
          args: {
            keyword: query.keyword,
            timeRange: query.timeRange,
            tag: query.tag,
            limit: 50,
          },
        }];

      case 'file':
        return [{
          toolName: 'query_files',
          args: {
            keyword: query.keyword,
            type: query.fileType,
            limit: 50,
          },
        }];

      case 'todo':
        return [{
          toolName: 'query_todos',
          args: {
            keyword: query.keyword,
            status: query.todoStatus ?? 'pending',
            sort: 'newest',
            limit: 50,
          },
        }];

      default:
        return [];
    }
  });
}

这里有三条重要边界。

1. 查询工具只能是只读工具

分类器说“需要工作区材料”,不代表它可以获得创建、修改或删除权限。

这一阶段只允许:

query_notes
query_bookmarks
query_files
query_todos

2. 用户身份不能由模型提供

模型只描述资源类型和筛选条件,userId、管理员上下文和资源主体由服务端会话注入。

任何模型输出的 userId 都不应该进入查询。

3. 查询上限由服务端决定

即使模型输出 limit: 100000,也不能允许它扫描整个账户历史。分类协议甚至没有必要暴露这个字段,服务端统一使用有界值。

五、不支持的条件,绝不能静默删除

结构化协议带来的一个常见错误是:字段无法映射时,删掉它继续执行。

假设分类器输出:

{
  "resourceType": "todo",
  "timeRange": "今天",
  "todoStatus": "pending"
}

但当前 query_todos 只支持状态和关键词,并不支持创建时间范围。

错误处理是:

query_todos({
  status: 'pending'
});

接口会正常返回,模型也会继续写作。

但用户要求的是“今天的未完成待办”,实际材料却变成了“全部未完成待办”。范围被悄悄扩大,而且整个链路没有任何报错。

在这里插入图片描述

更安全的策略是 fail-closed:

if (
  query.resourceType === 'todo' &&
  query.timeRange
) {
  throw new UnsupportedQueryFilterError(
    'todo.timeRange is not supported'
  );
}

随后可以:

  • 要求用户缩小或改写范围;
  • 回到 Planner 选择其他可验证能力;
  • 明确告知当前无法完整执行。

不要用更宽的数据集冒充正确结果。

AI 系统里最难排查的 Bug,往往不是请求失败,而是请求成功却回答了另一个问题。

六、第二次调用只负责写作,不再负责找资料

服务端完成只读查询后,第二次模型调用接收三类输入:

原始用户要求
查询得到的权威材料
明确的输出格式

然后只能调用一个工具:

submit_note_draft

协议可以非常小:

const DRAFT_TOOL = {
  type: 'function',
  function: {
    name: 'submit_note_draft',
    description:
      '提交一篇完成的 Markdown 草稿,供用户确认。',
    parameters: {
      type: 'object',
      additionalProperties: false,
      properties: {
        title: {
          type: 'string',
          maxLength: 255,
        },
        content: {
          type: 'string',
          maxLength: 60000,
        },
      },
      required: ['title', 'content'],
    },
  },
};

在这里插入图片描述

为什么还要强制 Tool Call,而不是让模型直接返回 Markdown?

因为闭合 Schema 能稳定保证:

  • 标题和正文分离;
  • 不出现多余执行参数;
  • 正文长度有上限;
  • 协议失败可以被识别;
  • 普通聊天文本不会被误当成完整草稿。

additionalProperties: false 还能阻止模型夹带:

{
  "userId": "...",
  "parentId": "...",
  "skipConfirmation": true
}

生成模型只拥有写作权,不拥有数据库权限。

七、对话历史不能代替权威材料

很多 Agent 为了节省查询,会直接使用历史消息中的内容。

这在普通问答中可能够用,在“把我的资料整理成一篇笔记”时却不可靠。

历史消息可能包含:

  • 上一轮工具结果的摘要;
  • 被截断的长正文;
  • 模型自己生成的解释;
  • 已经过期的标题;
  • 用户对材料的口头描述。

这些内容适合帮助模型理解对话,却不应该替代个人工作区中的真实记录。

因此,我把上下文分成两类:

会话上下文

用于理解:

“再写详细一点”
“换成周报格式”
“把第二节移到前面”

权威材料

来自本轮明确执行的只读查询,用于支撑正文事实。

只有后者能被当成草稿材料。

这种区分还能避免一个常见循环:

模型上一轮总结
        ↓
下一轮把总结当原始资料
        ↓
再次压缩与改写
        ↓
内容逐渐偏离真实来源

八、材料必须有预算,不能把整个工作区塞给模型

查询正确也不意味着应该全部放进上下文。

一个真实账户可能返回几十篇笔记、上百个书签和大量待办。直接拼接会导致:

  • Token 超限;
  • 前部材料被截断;
  • 模型注意力被低相关内容稀释;
  • 长正文拖慢每次草稿修改;
  • 私密字段进入不必要的日志或确认存储。

可以显式设置预算:

最多材料:12
材料总字符:28000
上一版草稿:24000
草稿正文:60000

超出预算时,系统必须做出明确选择:

  • 按相关度和时间排序;
  • 保留标题、类型、时间和必要正文;
  • 截断时增加标记;
  • 材料太多则要求缩小范围;
  • 不假装当前草稿覆盖了全部历史。

确认记录也不需要保存完整材料正文副本。

只保存稳定引用和原始要求:

{
  "sourceMessage": "总结我今天的发布计划",
  "contextRefs": [
    { "type": "note", "id": "note-1" },
    { "type": "todo", "id": "todo-2" }
  ]
}

用户确认或要求修改时,再由服务端重新检查这些资源是否仍然存在、是否仍属于当前主体。

九、为什么少一次模型调用,准确率反而可能更高

把 3 次调用改成 2 次,提升并不来自模型突然变聪明,而来自系统减少了不必要的非确定性。

范围只解释一次

旧链路:

分类器理解一次
Planner 再理解一次

新链路:

分类器一次输出完整范围
服务端按结构执行

查询参数可测试

workspaceQueries 是 JSON,可以直接写测试验证:

expect(result.workspaceQueries).toEqual([
  {
    resourceType: 'note',
    timeRange: '今天',
  },
]);

自然语言 Planner 的自由文本计划很难做到同样稳定。

上下文更干净

去掉一轮 Planner 消息后,草稿模型不再看到一大段中间推理和工具选择说明,只看到用户要求与真实材料。

错误更容易定位

新链路只有三个主要错误面:

分类错
查询错
生成错

而不是分类、规划、二轮规划、工具选择、材料拼接和生成混在一起。

延迟和成本同步下降

少一次 Provider 调用,也就少一次网络往返、排队、首 Token 等待和用量计算。

这类优化最有价值的地方,是准确性、成本和可维护性没有互相交换,而是同时改善。

十、测试应该围绕“范围有没有变”

除了普通的成功用例,我更关注下面这些测试。

任务类型

“总结这些材料,告诉我重点”
→ producesNote=false

“整理成一篇新笔记”
→ producesNote=true

“生成笔记并创建待办”
→ otherMutations=true

结构化范围

“今天的笔记”
→ note + timeRange=今天

“最近 7 天、标签为 AI 的书签”
→ bookmark + timeRange + tag

“还没完成的发布待办”
→ todo + keyword + pending

不支持字段

todo + timeRange
→ 显式失败,不静默删除

note + tag
→ 当前工具不支持则拒绝映射

file + todoStatus
→ 拒绝

材料权限

模型不能指定 userId
跨账号资源不进入材料
已删除资源不从标题快照恢复正文
管理员只读上下文不能扩大查询主体

草稿协议

必须只有一个 submit_note_draft
缺少 title / content → 拒绝
出现额外字段 → 拒绝
普通文本回复 → 视为协议失败

真正需要验证的不是“模型能不能写出一篇像样的文章”,而是:

最终草稿是否严格建立在用户指定的那一组材料上。

十一、什么时候不值得做这项优化

这套两阶段链路并不适合所有场景。

只有一个明确选中文档

用户已经选中一篇文章并要求“改写为摘要”,不需要额外分类工作区范围,可以直接进入生成阶段。

纯聊天问答

用户只问“这些材料讲了什么”,返回一段回答即可,不应该为了可能保存而额外调用分类器。

真正的多步骤复合任务

用户要求查询、创建笔记、创建待办并修改标签时,专用两阶段链路表达不了全部依赖,应交给通用 Planner。

优化的前提不是“调用越少越好”,而是:

一个结构化协议已经完整替代了原本那轮推理

如果只是删除 Planner,却没有其他组件接住它的职责,系统只会变得更快地答错。

结语

AI 总结个人笔记时,最值得警惕的并不是文笔差,而是它写得非常像,却根本没有读到用户以为它读到的那些材料。

解决这个问题,不能只继续修改 Prompt。

更可靠的办法是把链路拆成:

模型一次解释范围
服务端确定性读取
模型基于权威材料写作

这次从 3 次调用降到 2 次,表面上是性能优化,实际减少的是一次材料范围的重新解释。

在 AI 应用中,少一次推理有时不仅更省,也更可信。

完整实现来自开源项目轻笺,可参考:

https://github.com/VeteranBoLuo/light-note

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐