如何让 AI Agent 按需读取项目知识:Flow2Spec 的路由设计实践

AI 编程工具进入真实项目后,经常出现一种看似矛盾的情况:模型能完成复杂编码,却会反复询问一些项目里的基础事实。
例如,某个接口是否允许重试、批处理任务用什么幂等键、一个状态字段由哪个模块维护。这些问题通常已经在代码或文档里出现过,但新的会话并不知道上一次为什么这样实现,只能重新搜索仓库。
最直接的解决办法,是把规则都写进 AGENTS.md 或 CLAUDE.md。项目较小时,这种方式简单有效;项目持续演进后,单文件会越来越长。Agent 每次加载大量无关信息,不但消耗上下文,还可能忽略真正重要的约束。
我们在设计 Flow2Spec 时,把问题重新定义为:
项目知识不应该只是被保存,还应该能够按任务路由、展开依赖、检查缺口,并在代码变化后被重新验证。
本文不展开介绍所有功能,只讨论这套知识路由为什么这样设计,以及多人修改知识时如何处理语义冲突。
先明确三个设计目标
在实现之前,我们给知识层设定了三个目标。
第一,Agent 不应该为了一个支付需求读取整个项目文档。它需要一个低成本入口,先把候选范围缩小。
第二,命中一条业务规则不代表上下文已经完整。支付主题可能依赖账户风控、订单边界和统一错误码,依赖关系需要被显式表达。
第三,知识会随着代码变化。知识层必须能够进入 Git diff 和 Code Review,而不是成为一个无法追踪来源的外部黑盒。
基于这些约束,我们最终采用了仓库内的分层结构:
text
.Knowledge/
├── manifest-routing.json # L0:路由索引
├── matchers/ # L1:关键词分片
├── topics/ # L2:主题摘要与硬约束
├── stock-docs/ # L3:稳定的架构和能力文档
└── req-docs/ # L3:具体需求与技术方案
这里的重点不是目录名称,而是每一层承担的职责不同。
| 层级 | 保存内容 | Agent 的读取方式 |
|---|---|---|
| L0 路由索引 | task、topic 路径、依赖和元数据 | 会话先读取机读入口 |
| L1 匹配分片 | 一组与任务相关的触发词 | 只打开可能命中的分片 |
| L2 主题摘要 | 边界、硬约束和下钻入口 | 命中后读取并展开依赖 |
| L3 长文档 | 架构终稿、需求方案和完整背景 | 信息不足时按需读取 |
这样做的目的,是让常见问题尽量在 L0 到 L2 解决,只有遇到信息缺口时才继续读取长文档或源码。
路由入口为什么要做成 manifest
manifest-routing.json 是知识层的机读入口。下面是一段经过简化的结构:
json
{
"topicPaths": {
"flow2spec-collaboration": ".Knowledge/topics/flow2spec-collaboration.md",
"f2s-task": ".Knowledge/topics/f2s-task.md"
},
"topicDependencies": {
"flow2spec-collaboration": ["f2s-task"]
},
"taskToTopicRules": [
{
"task": "flow2spec-collaboration",
"matcherId": "m-flow2spec-collaboration",
"matcherPath": ".Knowledge/matchers/m-flow2spec-collaboration.json",
"topics": ["flow2spec-collaboration"]
}
]
}
这里包含三种关系:
taskToTopicRules负责从任务找到 matcher 和候选 topic;topicPaths负责从 topic id 找到实际文件;topicDependencies负责声明命中主题前需要补齐哪些前置主题。
路由索引只保存关系,不把所有关键词直接塞进一个大 JSON。关键词被拆到独立 matcher 中:
json
{
"id": "m-flow2spec-collaboration",
"schema": "flow2spec.matcher.v1",
"includeAny": [
"团队协作",
"多人共用知识库",
"developerId 隔离",
"kb-delta",
"topic revision",
"revision 冲突",
"optimistic lock"
]
}
分片带来的好处是修改局部规则时,其他路由文件不会产生无意义 diff。Agent 也不需要一次读取所有关键词,只需通过 matcherPath 打开相关分片。
这不是语义检索的替代品,而是一种偏确定性的仓内协议。它牺牲了一部分模糊召回能力,换来可读、可审查和可预测的路由结果。对于权限、幂等、数据边界这类不能靠"相似度大概命中"的约束,这个取舍更合适。
Match 之后不能直接 Act
仅仅命中一个 topic,很容易制造虚假的确定性。
假设用户说:"两个人同时更新同一个业务主题时,知识库会不会把内容覆盖掉?"matcher 可以命中协作主题,但真正回答之前还需要确认:
- 当前 topic 是否覆盖并发写入;
- 是否需要先读取任务状态的所有权规则;
- 用户问的是 Git 文本冲突,还是业务语义冲突;
- topic 中的描述是否仍与当前实现一致。
因此完整流水线被拆成四步:
text
match → expand → verify → act
Match:缩小候选范围
根据用户任务读取对应 matcher,得到主候选 topic,而不是遍历全部知识文件。
Expand:展开依赖
读取主 topic 声明的依赖。例如协作主题依赖任务主题,因为知识合并前必须先知道当前开发者的 TASK_ROOT 在哪里。
Verify:检查缺口
判断现有知识是否真的覆盖问题。覆盖不足时继续读取 stock-docs/、req-docs/ 或源码;需求本身不清楚时先向用户确认。
Act:执行任务
只有依赖完整、关键事实得到确认后,才进入回答、修改代码或提交变更。
verify 是这条链路里最容易被省略、也最重要的一步。检索解决的是"可能相关",缺口检查解决的才是"是否足以行动"。
Topic 为什么只保存精简事实
topic 的目标不是复制完整文档,而是提供 Agent 最常用的硬约束和下钻入口。一个真实 topic 的 frontmatter 类似这样:
yaml
---
id: flow2spec-collaboration
revision: 0
summary: 任务状态本地隔离、共享知识 delta 合入与 revision 冲突处理
dependsOn: [f2s-task]
primary: feature
confidence: manual
tags: [policy]
---
正文只保留协作边界、知识合入规则、团队观察面和长文档路径。这使 topic 足够短,可以在一次任务中组合多个主题;完整解释仍然放在长文档里。
这里还有一个容易踩坑的地方:摘要不能写成宣传文案。诸如"强大、智能、高效"对路由没有帮助。更有效的摘要应该直接描述事实、适用范围和限制。
开发完成后,知识怎样写回来
只读知识还不够。Agent 在实现过程中经常会从源码确认新的限制,例如退款只能原路返回,或者某个锁的 TTL 是 10 分钟。如果这些事实只留在当前会话里,下次仍然要重新搜索。
直接让 Agent 修改 topic 虽然简单,但多人协作时会出现两个问题:
- Git 只能看到文本变化,不知道这次修改的意图;
- 两段文本可以自动合并,不代表两条业务规则在语义上兼容。
因此知识变更先写成结构化 kb-delta.json:
json
{
"taskId": "add-payment-rule",
"developerId": "alice",
"baseRevisions": {
"payment-rules": 3
},
"changes": [
{
"type": "appendBody",
"targetTopic": "payment-rules",
"summary": "补充退款时限",
"content": "## 退款时限\n\n审核通过后 3 个工作日内原路退回。"
}
]
}
delta 目前只允许四种动作:
| 类型 | 含义 |
|---|---|
appendBody |
在已有 topic 末尾追加内容 |
replaceBody |
替换 topic 正文 |
updateFrontmatter |
更新 topic 元数据 |
createTopic |
创建 topic,并按需建立 matcher 和路由 |
动作白名单让 CLI 可以在落盘前校验目标、字段和版本,也让 Code Review 更容易理解这次知识变更的意图。
用 topic revision 阻止过期写入
baseRevisions 记录生成 delta 时看到的 topic 版本。执行 plan 时,CLI 会把它与磁盘上的 revision 比较:
text
baseRevision == diskRevision → 可以 apply,写入后 revision +1
baseRevision != diskRevision → 停止,要求重新读取最新内容
例如 Alice 和 Bob 都基于 payment-rules revision: 3 开始修改。Alice 先合入后,磁盘版本变为 4。Bob 的 delta 再执行 plan 时会得到 revision mismatch,而不是继续写入。
Bob 此时必须阅读 revision 4 的正文,再判断两份规则应该并列、改写,还是放弃其中一份。这里故意没有做自动文本拼接,因为"两个段落都能插进去"和"两个业务结论不矛盾"是两回事。
revision 是磁盘乐观锁,不是远程分布式锁。它有明确边界:
- 它只能保护通过 delta 通道提交的知识变更;
- 它不知道队友尚未拉取的远端提交;
- 直接手改 topic 会绕过 revision 预检;
- 正常的 Git pull、分支同步和 Code Review 仍然不可省略。
这个机制没有消灭冲突,而是尽量把冲突提前到 plan 阶段,并把语义裁决留给掌握业务上下文的人。
任务状态和项目知识为什么要分开
多人使用 Agent 时,仓库里会同时出现两类状态:
- "这轮会话做到哪一步"属于个人过程;
- "系统当前有哪些约束"属于团队事实。
如果把两者都提交到 Git,个人 checklist、临时判断和待办项会频繁冲突。反过来,如果两者都放在本地,已经验证的业务知识又无法共享。
我们采用的边界是:
text
.task/<developerId>/ 本地任务现场,默认不进 Git
.Knowledge/ 团队项目事实,随代码进入 Git
.task/ 保存 checklist、会话上下文和本轮 delta,用于跨会话续作;.Knowledge/ 只接收已经确认的事实。团队进度仍然通过 PR、commit、issue 和里程碑观察,不把个人 Agent 会话同步成另一套项目管理系统。
一次最小初始化实测
为了确认这套结构不是只存在于文档里,我在一个空 Git 仓库中执行了 Codex 初始化:
bash
npx @double-coding/flow2spec@latest init codex --locale zh-CN --yes
npx @double-coding/flow2spec@latest doctor
初始化生成了 .Knowledge/、.codex/、根 AGENTS.md 和 flow2spec.config.json,同时把 .task/ 加入 .gitignore。
doctor 的检查结果为 8 项通过、0 个警告、0 个错误,覆盖:
- Node.js 版本;
- 项目配置;
- Agent 入口;
- 知识库入口;
- Codex 配置完整性;
- developerId 和
TASK_ROOT; .task/忽略规则;- topic 校验与 routing 漂移。
这只能说明初始化链路和基础结构工作正常,不能证明路由质量。路由是否真正有效,仍然取决于团队有没有把 topic 写成明确事实、matcher 是否覆盖真实表达,以及代码变化后是否及时同步知识。
实际使用中的几个限制
这套方案并不是零成本的。
首先,matcher 使用显式触发词,结果容易解释,但对团队从未预料过的表达召回能力有限。必要时仍然要靠普通代码搜索或其他检索方式兜底。
其次,topic 越大,并发修改时 revision 冲突面越大;topic 拆得太细,又会导致依赖关系复杂。比较可行的标准是让一个 topic 围绕一组稳定、能独立判断的业务约束,而不是按文件数量机械拆分。
再次,知识正确性最终仍然来自代码、测试和人工确认。confidence、revision 和校验命令只能帮助管理知识,不能把未经验证的推断变成事实。
最后,小项目未必需要这套结构。一次性脚本或只有少量文件的个人项目,用一份简洁的规则文件通常更直接。只有当重复搜索、上下文漂移和多人知识冲突开始产生明显成本时,分层路由才值得维护。
总结
让 AI 了解项目,不等于把更多文本塞进上下文。更关键的问题是:怎样找到相关事实、怎样补齐依赖、怎样判断信息足够,以及事实变化后怎样安全写回。
Flow2Spec 当前给出的答案可以概括为三点:
- 用 manifest、matcher、topic 和长文档组成渐进式知识层;
- 用
match → expand → verify → act把缺口检查放在执行之前; - 用结构化 delta 和 topic revision 管理多人知识变更。
这套设计仍然需要在更多真实项目中验证,尤其是 matcher 的长期维护成本、topic 拆分粒度和跨分支语义冲突。但至少有一点已经比较明确:项目上下文不能只被当成提示词,它应该成为可以被版本控制和持续维护的工程资产。
文中的完整实现和 schema 可以在 Flow2Spec 仓库 中查看。