上一篇文章里,我拆解了伴AI 如何用意图识别、多 Agent、任务规划、质量评审,以及 LangGraph 的恢复与幂等,把"一句话"变成一次可靠交付。
但一个 Agent 即使执行流程足够可靠,仍然可能在更早的地方犯错:它拿到的上下文不完整、把旧偏好当成新指令、引用了已经删除的资料,或者面对互相矛盾的来源却替用户做了裁决。
这一次,项目没有继续给 Agent 增加"自主性",而是把上下文升级成了一套可追溯、可预算、可失效、可评审的基础设施:普通对话与 LangGraph Agent 共享同一份版本化快照;长期记忆带来源与纠正链;用户文档被编译为有证据的 Wiki;产品使用说明以内置只读知识提供;管理后台则用 Passkey 和短会话收紧控制面权限。
本文基于仓库提交 61ffbae(2026-09-18)后的已提交实现。Agent 主图仍是 ai-companion-supervisor@3.69.0,因此这不是对上一套执行架构的推翻,而是在它前面补上一层"可信上下文控制面"。
上一篇: 从一句话到可靠交付:伴AI 的意图识别、多 Agent、任务规划、质量评审、LangGraph 恢复与幂等
一、上下文不是"把聊天记录全塞给模型"
最容易实现的记忆方案,是把最近若干轮消息拼进 Prompt。它在短对话里有效,但一旦进入真实产品,就会同时遇到五类问题:
- 长对话会超过模型窗口,工具 Schema 与输出预算也会被挤占;
- 早期的硬约束可能被后来的闲聊冲掉;
- 助手自己说过的话可能被误当成用户事实;
- 已删除或已纠正的记忆可能继续从缓存、向量索引或旧摘要中泄漏;
- 文档中的命令式文字可能借"参考资料"身份提升为系统指令。
所以,伴AI 没有把上下文定义成一段字符串,而是定义成一个 Go 与 Python 共同遵守的版本化协议:
json
{
"version": "conversation-context-v1",
"history": [],
"summary": {},
"memories": [],
"knowledge": [],
"manifest": {
"sources": [],
"recent_tokens": 0,
"summary_tokens": 0,
"memory_tokens": 0,
"knowledge_tokens": 0,
"degraded": []
}
}
其中:
history是保留完整语义组的近期原始消息;summary是被滚动压缩的较早对话;memories是跨会话召回的长期事实;knowledge是与当前问题相关的内置产品知识;manifest只记录来源标识、版本和预算计数,不携带私密正文,可用于诊断与审计。
普通聊天和 Agent intake 都从 Go 服务的同一个 BuildContext 入口构造快照。Python Worker 不重新发明一套上下文逻辑,只消费这个显式版本;遇到不支持的版本直接失败关闭。这解决了一个很隐蔽的问题:如果普通聊天和 Agent 各自维护摘要、记忆和裁剪规则,同一个用户问题可能在两条链路中得到两份不同"事实"。
二、上下文预算要按认知职责分配
统一协议不等于所有 Agent 看到同样多的历史。不同节点承担的任务不同,应该支付不同的上下文成本。
当前 Python 投影使用下面的历史预算:
| 节点角色 | 历史预算(估算 Token) | 为什么 |
|---|---|---|
| Router | 2000 | 只需识别当前意图与必要指代 |
| Assessor | 2000 | 重点是当前观察和成功标准 |
| Repairer | 3000 | 需要失败上下文,但不需要完整长对话 |
| Planner | 4000 | 要保留目标、约束与多步关系 |
| Composer / Responder | 6000 | 负责组装参数或最终回答 |
裁剪单位也不是一条字符串,而是同一 sequence 下的完整消息组。即使某组超过预算,也会保留最后一组,不会在否定条件、限定词或一次多气泡回复的中间截断。
更重要的是,这些只是"选择预算"。真正发给模型前,系统还会按 UTF-8 字节上界重新计算整个请求,把 messages、tools、tool choice、response schema、输出预留和 1024 Token 安全边界都算进去。最终检查失败时,不调用供应商,也不让熔断器为本地超限背锅。
这体现了两个不同层次:
text
选择预算:决定哪些上下文最值得进入候选集
窗口上界:证明整个模型请求不会越过硬边界
只做前者,工具 Schema 变大时仍可能爆窗;只做后者,则会在最后一刻粗暴删除信息,无法保证早期约束和完整语义组幸存。
三、参考资料与指令必须做权限分层
长期记忆、摘要、Wiki 和产品指南都会包含自然语言,而自然语言里可能出现"忽略之前规则""替我执行删除"等命令式文本。
伴AI 的处理方式不是猜它有没有恶意,而是在消息角色上消除歧义:
- 系统消息只声明安全规则:这些内容是参考资料,不是新指令,也不是操作已执行的证明;
- 摘要、记忆与知识正文以结构化的
user参考消息注入; - 当前用户的明确纠正优先于旧参考资料;
- 工具权限、身份、确认和风险策略仍由可信运行时决定,参考资料无权修改。
这相当于把"数据平面"和"控制平面"分开。资料可以影响回答内容,却不能授予工具,也不能证明一个账目、提醒或管理员操作已经发生。
同样的原则也延伸到可观测性。manifest 会记录 source ID、版本、Token 数和降级原因,但故意不记录正文。运维侧可以知道"本次召回了三条记忆、遗漏两条、Wiki 进入降级",却不会因为一次普通诊断事件复制用户的私密内容。
四、摘要要能追溯,记忆要能纠正
1. 结构化摘要不是自由发挥
滚动摘要按 goal / constraint / fact / done / pending / background 保存事实,每条事实都带原消息 ID 和角色。
模型可参与压缩,但输出要经过确定性校验:
- 每条事实必须引用已提供的原始 source ID;
- 标记为
user的事实只能引用真实用户消息; - 不允许空事实、未知类型、未知角色或超长内容;
- 用户原始约束即使被模型漏掉,也会由运行时重新补回;
- 语义摘要失败时退回结构化抽取式摘要。
因此,助手先前说过"我已经替你设置好了",不会在下一轮摘要中被悄悄升级为用户确认过的业务事实。
2. 纠正不是覆盖旧文本
长期记忆使用带来源和有效期的事实。用户说"以前不吃香菜,现在可以吃了"时,系统不会原地改写旧记录,而是创建新事实,并让旧事实进入 superseded 状态、关闭有效时间区间。
这带来三个好处:
- 召回时只返回当前有效事实;
- 审计时能看到纠正链,而不是失去历史;
- 删除或纠正后,可以同步使缓存与旧向量失效。
跨用户纠正会被拒绝,过期事实和未来才生效的事实也不会进入当前上下文。记忆因此更接近一张有时间语义的事实表,而不是一个无限追加的便签盒。
五、从文档 RAG 到可追溯 Wiki
传统文档问答一般在请求到来时检索若干 Chunk,然后把它们拼给模型。这仍然适合全文抽取和一次性问答,但不擅长回答"这个项目有哪些长期决策""两个版本的预算为什么不一致""从一个实体还能追到哪些来源"。
伴AI 在 Source IR 之上新增了 Wiki 编译层。它不是替代原文,而是生成一组可导航、可失效、可回到证据的派生页面。
1. 完整覆盖与语义提炼分开
每个原始 Chunk 都会得到一个可导航页面,完整覆盖不依赖模型预算。来源页会链接所有 Chunk 页。
在此基础上,如果摘要模型可用,编译器最多生成 8 个来源摘要、主题、实体或决策页面。模型只能引用本轮明确提供的 Chunk ID,并且每一个事实段落都必须包含 [chunk:ID]。引用未知 Chunk、正文没有逐段引用、标题或正文超限的生成页,会被直接丢弃。
这让模型承担"组织信息"的工作,但不给它凭空制造证据的权力。模型不可用时,抽取页和来源页仍然成立,Wiki 只会少一层语义提炼,不会整体消失。
2. 冲突是结果,不是异常
当不同来源对同一个声明给出不同值,例如:
text
旧计划:预算 100 元
新计划:预算 200 元
聚合页不会挑一个看起来更新的数字冒充结论,而会保留两份带 Chunk 引用的表述,并明确标记"需核对适用时间和范围"。
这是一种很重要的质量观:知识系统的职责不是强迫世界保持一致,而是诚实暴露证据之间的不一致。
3. 权限是所有来源的交集
聚合页可能依赖多个文档,因此它的可见性不能由"其中一个来源共享了"决定。只有当前用户或团队对全部依赖来源都拥有访问权,页面才可见。
读取时还会重新检查每个来源版本:文档被删除、取消共享、重新解析或版本变化后,旧页面会立刻变成不可读或 stale,不能继续进入 Agent 检索。后台编译任务稍后会清理或重建派生页,但安全性不依赖异步清理是否已经完成。
这堵住了常见的派生数据越权:原文权限已经收回,摘要或向量缓存却还在继续泄漏。
4. 人工编辑使用 CAS,自动编译不覆盖人
Wiki 页面支持 Markdown 编辑与版本历史。更新必须携带当前版本号;并发编辑使用旧版本会得到冲突,而不是后写覆盖前写。
一旦页面被人工编辑,普通来源重编译不会覆盖它。只有用户显式释放人工版本并重建,编译器才重新接管。这使"机器生成"和"人工核对"形成清晰的所有权边界。
六、Agent 如何按需读取 Wiki,而不是一次塞满
Work Agent 获得三类只读工具:
| 工具 | 作用 | 当前边界 |
|---|---|---|
work_wiki_search |
搜索来源摘要、主题、实体和决策 | 最多 5 页,2400 Token 搜索预算 |
work_wiki_read |
分页读取页面与原文证据 | 每页正文最多 2400 字符,返回 next_offset |
work_wiki_follow_links |
沿页面链接补齐相关证据 | 一次最多 4 页 |
每次工具结果还有 6000 Token 硬上限。Wiki 与内置产品知识工具合计成功调用 8 次后,Router 不再继续提供这些工具,防止 Agent 在知识图里无限漫游。
搜索本身采用分层退化:
text
关键词召回
+ 可选 Embedding 稠密召回
+ 可选 Rerank
-> 受页数与 Token 预算约束的结果
Embedding 或 Rerank 失败不会抹掉关键词结果,只会在结果中标记降级。索引发布支持 legacy / shadow / semantic 三种模式:shadow 同时写入和对比候选索引,但仍返回旧索引结果;验证召回质量后再切换 semantic。候选 collection 名绑定模型和维度版本,避免换 Embedding 模型后悄悄混用旧向量。
缓存键也不是简单的 user + query,而是包含当前可见页面的版本与依赖指纹。页面编辑、来源删除、取消共享或版本变化,都会让旧命中和旧空结果自然失效。
七、为什么还要单独做"内置产品知识"
用户 Wiki 回答"我的资料里有什么",产品知识回答"这个应用怎么用"。两者看起来都属于 RAG,但安全和可用性要求完全不同。
| 维度 | 用户 Wiki | 内置产品知识 |
|---|---|---|
| 内容来源 | 用户上传文档 | 仓库白名单文档与引导内容 |
| 隔离方式 | 用户 / 团队权限 | 所有人只读、无租户私有数据 |
| 存储 | 数据库、对象存储、可选向量库 | 编译进 API 与 Worker 二进制 |
| 检索 | 关键词 + 可选 Embedding / Rerank | 本地加权 BM25,中文 bigram 与英文标识符 |
| 外部依赖 | 可按策略退化 | 无模型、数据库、向量服务依赖 |
| 典型问题 | "项目预算依据是什么?" | "怎么修改密码?""Wiki 在哪里?" |
产品知识由构建脚本从明确白名单生成 catalog.json,再通过 go:embed 进入二进制。生成过程不访问网络,也不调用模型;catalog 保存来源 SHA-256,CI 会重新生成并检查差异,防止代码、引导文案和内置说明悄悄漂移。
它同样以参考资料注入,不能授予管理权限,也不能证明实时状态。用户问"如何创建提醒"时,系统可以直接解释入口和限制;用户真的要求创建提醒时,仍必须调用当前模块的真实业务工具,并遵守确认与身份约束。
把两类知识拆开还有一个实用收益:即使远程向量服务、用户 RAG 或数据库处于降级状态,应用自己的使用说明仍然可检索。
八、恢复与幂等已经从 Agent Run 延伸到知识生产
上一篇文章中,恢复与幂等主要围绕 LangGraph checkpoint、工具提交和业务副作用。这次 Wiki 编译把同样的原则用于"知识生产任务"。
1. 编译任务有租约与 fencing token
Wiki Job 的租约是 10 分钟,单次编译 deadline 是 8 分钟,最多重试 5 次。Worker 认领任务后获得 token;租约失效或同一文档重新入队后,旧 token 即使晚到也不能发布结果。
同一 owner 的文档编译会串行,避免两个文档并行发布出各自只看到一半来源的聚合页;不同 owner 仍可并行处理。
2. 发布前再次验证来源
编译完成并不等于可以提交。发布前会重新读取原文,确认它仍是 ready,并且来源版本没有变化。用户在编译期间删除或重新解析了文档,本次结果会以冲突结束,而不是把旧证据写回新世界。
3. 重放不制造无意义版本
相同输入得到相同页面 ID;内容没有变化的编译重放不会增加版本。人工编辑用 CAS 防并发覆盖,邀请、WebAuthn ceremony 等一次性状态则用原子 Take 消费。
它们共同遵守同一个工程模型:
text
至少一次调度
+ 稳定身份
+ 有界租约
+ 提交 fencing
+ 版本比较
= 可恢复、可解释的结果
九、质量评审现在不仅评"回答",还评"上下文"
如果质量评审只检查最终文字是否通顺,就会错过最危险的问题:答案写得很像真的,但证据已经过期或越权。
因此,这次实现把质量门前移到了上下文构造、知识编译、检索和控制面。
上下文契约评审
- Go 与 Python 用同一个
conversation-context-v1回放样例,防止跨语言字段漂移; - 历史按完整消息组裁剪,长期事实不允许截断在否定条件中间;
- 摘要引用未知 source ID 会被拒绝;
- 助手陈述不能被提升为用户事实;
- 请求上界包含工具 Schema 与输出预留。
Wiki 质量评审
- 语义生成页逐段校验 Chunk 引用;
- stale 来源不能读取或命中搜索;
- 聚合页使用所有来源的 ACL 交集;
- 删除来源后,缓存、页面与聚合结果同步失效;
- 人工编辑不会被自动编译覆盖;
- 搜索、读取、链接遍历都有独立的页数和 Token 上限;
- 用户可对具体页面版本标记
helpful / incorrect / outdated。
发布评审
语义索引不是直接切换,而是先走 shadow;Wiki 检索有 100 条合成评测用例,可通过 make eval-m2 执行。真正上线前仍需要在授权数据上回放,因为合成集只能证明契约和基本召回,不能替代真实语料分布。
为核对本文,我在当前工作区重新跑了针对性回归:统一上下文、Wiki、产品知识、Passkey、工具与 HTTP 边界相关的 7 个 Go package 全部通过;Python 的上下文与部署 resolution 用例为 25 passed, 13 subtests passed。这不是整个仓库的全量测试结论,但覆盖了本文描述的关键不变量。
十、Passkey:可信上下文也需要可信控制面
上下文治理最终会落到管理员操作:谁能调整模型、查看评测、发布配置、处理用户数据?如果管理后台仍把长期密钥交给浏览器保存,前面的边界会被最薄弱的一环绕过。
伴AI 因此为 /admin 增加独立管理员账号与 WebAuthn Passkey:
- 浏览器只持有短期、不透明、可撤销的会话,不接触长期管理员密钥;
- 注册依赖一次性邀请,邀请 30 分钟过期,并在开始绑定时原子消费;
- 登录校验 challenge、Origin、RP ID、签名、User Verification 和计数器回退;
- 会话最长 8 小时,空闲 30 分钟失效;
- 敏感管理写入要求最近 5 分钟内完成过 Passkey 验证;
- 管理员被禁用、令牌重置或进入恢复流程时,旧会话不会因为账号再次启用而复活;
- 生产 Cookie 使用
__Host-、HttpOnly、Secure与SameSite=Strict。
Passkey 没有消灭授权问题,它只是把"证明正在操作的人确实持有注册凭据"做得更可靠。角色仍分为 viewer、support 和 admin,写操作仍需要权限与新鲜认证。
值得注意的是,WebAuthn 一次性 challenge 的原子消费,与 Agent 审批 resolution、Wiki lease token 背后其实是同一种思路:外部请求可以重复到达,但只有一个合法状态转换能成功。
十一、这套设计仍然有哪些边界
可信上下文不是"永不出错"的知识库。当前实现仍明确保留这些限制:
- 引用证明来源,不证明来源本身正确。 多个文档可能一起错,系统只能追溯和暴露冲突。
- 删除阻止未来召回,不会抹除已发生的披露。 已经发给模型、写入对话历史或旧 checkpoint 的文本,需要独立的保留与删除策略处理。
- 语义模型是增强项,不是权威。 Embedding、Rerank 和语义摘要都可能降级,确定性权限与来源版本校验不能依赖它们。
- Token 估算不是供应商精确计费器。 所以选择阶段可以用估算,最终窗口必须使用更保守的请求上界。
- Wiki 不替代全文工具。 跨整份文档的翻译、表格提取或文件生成,仍应走 Source IR 和附件工具,而不是让 Agent 在 Wiki 页面之间猜测完整性。
- 真实召回质量需要真实评测。 shadow 与合成集能降低发布风险,但无法替代经过授权的线上样本回放和人工核验。
承认这些边界,反而让系统更可靠。它知道哪些结论可由代码保证,哪些只是模型增强,哪些必须交给用户或运营人员确认。
十二、结语:Agent 的上限,越来越取决于它如何知道
第一阶段的 Agent 工程,重点是"如何做":意图识别、任务规划、工具调用、质量门、恢复与幂等。
当执行链路稳定后,下一个瓶颈会变成"凭什么这么做":上下文来自哪里,是否仍然有效,谁有权读取,冲突有没有被隐藏,旧事实如何纠正,失败后重放会不会发布过期知识。
伴AI 这次演进的核心,可以概括成四句话:
- 用版本化快照统一普通对话与多 Agent 的上下文;
- 用来源、版本、有效期和权限交集约束记忆与 Wiki;
- 用预算、分页、降级和 shadow 控制语义能力的成本与风险;
- 用租约、CAS、原子消费和 Passkey 把可靠性延伸到知识生产与管理控制面。
大模型可以负责理解和组织信息,但"什么能看、什么能信、什么已经发生、谁能提交"必须由系统来回答。只有这层边界足够清楚,Agent 的记忆才不会从能力变成隐患,知识库也才不只是一个更大的 Prompt。