首页 核心备考 STAR 案例 评估与护栏 评估深入 护栏深入 核心概念 意图识别 EN

先讲概念,再看代码

记忆、上下文管理、编排

这三个术语常被当成同义词换着用,但它们并不能互相替代。 本页从每一个术语所要解决的问题出发,把它一层层搭起来,然后精确指出它在 Mines Financial 代码库中的位置 —— 逐个文件、逐个数字地对照。

从这里开始 · 制造出全部三个问题的两个事实

本页所有内容,都是从语言模型的两个属性推导出来的。这两点都不是 bug, 也都不是换一个更好的模型就能解决的。

第一:模型是无状态的。 当你的程序调用模型时,它发过去一段文本, 再拿回一段文本。模型什么都不会留下。下一次调用对上一次调用一无所知。当 ChatGPT 看起来记得你之前说过的话时,那并不是模型在记忆 —— 而是应用程序在你看不见的地方, 把整段对话当作新请求的一部分重新发了一遍。所有看起来像记忆的东西, 都是外面那层程序自己搭出来的。

第二:模型的输入长度有固定上限。 它在一次调用中能读进去的文本量存在一个最大值, 这个上限叫做 上下文窗口(context window)。 而且因为模型什么都不保留,你希望它考虑的全部内容,必须在 每一次 调用里都塞进这个上限之内,而且每一次你都要为这全部内容付费。

把这两点放在一起,就自然掉出三个彼此独立的工程问题 —— 也正是本页的三个主题:

记忆系统

模型什么都不保留,那么 我们要在模型之外存什么,存在哪里? 这是一个存储与生命周期的问题。

上下文管理

窗口既有限又昂贵,那么 在我们已经存下来的所有东西里,哪些要放进这一次调用? 这是一个筛选与组装的问题。

编排

单次调用无法可靠地完成多步骤的工作,那么 要跑哪一串调用,又由谁来决定? 这是一个控制流的问题。

如何阅读本页 正文就是讲解本身 —— 它是写来一路读下去的。凡是带一个小 问号 的地方,点一下就会展开定义;旁枝性的延伸内容则折叠在 + 按钮后面,好让主线保持连贯。

区分这三者 · 大多数讲解都会跳过的那条界线

这三者在实践中确实互相重叠,所以人们容易把它们混为一谈。要把它们分清楚, 最干净的办法是去问每一个各自 拥有什么.

 拥有什么时间尺度失败方式
记忆 我们保留什么,保留多久 跨调用、跨会话、跨数周 记住了错误的东西,或者永远记着不放
上下文管理 在某一次具体调用里发送什么 一次调用 发得太多、发得太少,或者顺序不对
编排 哪些调用会发生,以什么顺序发生 一次请求,从开始到结束 走错分支、无限循环、无法观测的失败

下面是一个同时用到三者的具体例子。用户问「那他们的分红呢?」。

  • 记忆 提供了「上一个问题问的是 BHP 和 FY2024」这个事实。 这个事实是更早的时候存下来的,现在被读了出来。
  • 上下文管理 决定路由模型只拿到补全后的问题和它自己的指令 —— 不给对话历史,也不给检索到的文档,因为那个模型唯一的任务就是分类。
  • 编排 决定:因为这个问题需要报告数据,所以检索子图要在回答步骤之前运行; 而如果缓存中已经命中了一个答案,回答步骤就整个跳过。
要记住的一句话 记忆是你所 拥有的。上下文是你所 发送的。编排则是实际 运行的。一份存下来却没有被发送的检索文档,就是有记忆而无上下文。一次收到了完美上下文、 却发生在流程中错误位置的模型调用,是上下文层面的成功,同时是编排层面的失败。

1 · 记忆系统

在模型之外的存储,并且带有明确的生命周期。

概念本身 · 从最笨的版本讲起

对付无状态最朴素的做法很显然,而且几乎每个项目都是从这里起步的: 把整段对话放进一个列表,每次都把整个列表发过去。 这在一段时间内确实管用,而且它就是正确的第一个版本。

随着对话变长,它会分三个阶段崩掉。

  • 第一,成本。 如果第二十轮要把前面十九轮全部重发一遍, 你就为同一段文本付了二十次钱。成本是随对话长度的平方增长的,而不是线性增长。
  • 第二,质量 —— 这一点常常出乎人们意料。 模型对输入开头和结尾的注意力最稳定。 一个埋在超长输入正中间的关键数字,被用上的概率明显低于同一个数字出现在一段短输入里的情况。 所以更长的上下文,反而可能产出更 糟糕 的答案。
  • 最后,硬性失败。 输入最终会超出窗口,调用直接报错。

所以,当「全都发过去」不再奏效时,你要动手搭的东西就是记忆系统。而最核心的一点认识是: 「记忆」并不是一个东西。它其实是四种共用同一个名字的不同需求, 各自有不同的存储、不同的生命周期,出错时也有不同的后果。把它们当成一个组件来设计, 是最常见的错误。

四种记忆 · 各自是干什么用的

1
工作状态 —— 单次请求内部的记忆

在一次请求被处理的过程中,各个步骤之间需要互相传递东西:改写后的查询、 检索到的文档、答案草稿、各项判定结果。这是「局部变量也算一种记忆」意义上的记忆。 它只为一次请求而存在,请求结束之后就应该消失。

它经常和其他几种记忆被搞混,而且是双向搞混:有人把需要长期存活的事实存在这里, 请求一结束就丢了;也有人把只属于单次请求的临时数据塞进数据库,然后永远不清理。

在本项目中 一个 MainState 类型化字典,定义在 state.py —— 大约二十个字段,包括 query, aggregated_context, needs_retrieval, guardrails_passed, confidence。检索子图有它自己独立的 RetrievalState,所以它内部的工作字段永远不会泄漏到父图里。
2
会话持久化 —— 熬过一次重启

一段对话必须比处理它的那个进程活得更久。服务器重启、发布上线、 用户明天再回来 —— 这个会话线程都必须还在。所以工作状态必须写到某个持久化的地方。

解决这件事的模式叫做 检查点(checkpointing)。 它换来的不只是持久化,而且往往后一半价值更大:因为每一个中间状态都被写了下来, 事后很久你仍然可以精确重建出系统在每一步看到的东西。

在本项目中 graph.py 在一个连接池之上接了一个 PostgreSQL 检查点器(最大连接数 10,开启自动提交)。每个节点执行完之后都会写入完整状态, 以会话线程 id 作为键。这也正是澄清式暂停之所以能实现的原因 —— 见下文的编排部分。
3
长期记忆 —— 比会话活得更久的事实

那些下周还应该知道的东西:用户偏好、正在讨论的实体、之前已经得出的结论。 智能体(agent)相关的文献通常把这一类再分成三种 —— 情景记忆、语义记忆和程序性记忆 —— 但工程上的问题要简单得多:键是什么,以及什么条件会让这条记录失效?

这一类的失败模式是最糟糕的,因为一条错误的长期记忆会被以十足的自信讲出来, 而且永远不会被重新检查。

在本项目中 只做了一部分,这里值得说得准确一点。项目里没有用户画像, 也没有跨会话的事实存储。已经有的是对话内部的实体延续 —— 从最近 6 条消息中扫描出公司代码和财年,实现在 nodes/memory.py —— 再加上下面那个答案缓存, 它的存活时间很长,但键是问题本身,而不是用户。
4
缓存 —— 不重复已经做过的工作

严格来说这是一种优化,而不是记忆,但它会被存下来、之后会被读回来、 而且可能是错的 —— 所以它的行为方式和记忆一样,也需要同样的纪律。

普通缓存匹配的是完全相同的文本,这对问题来说毫无用处:「BHP revenue FY2024」 和「How much did BHP earn in FY2024?」是两个不同的字符串,问的却是同一件事。而 语义缓存(semantic cache) 比较的则是含义。

在本项目中 memory/semantic.py 里有一个独立的向量集合,名叫 conclusions,与存放源文档的 reports 集合区分开 —— 这样系统就绝不可能把自己过去的答案当成年报来引用。 两档阈值:相似度达到或超过 0.95 时,直接返回存下来的答案并跳过检索;在 0.80 到 0.95 之间时,仍然跑完整条流水线, 但把旧答案作为参考材料一并传进去。
为什么写路径比读路径更重要 —— 缓存投毒

缓存检索到的 文档 ,最多只会过期。而缓存系统自己产出的 答案 ,则可能是错的 —— 而且一旦写进去,它就会被自信地端给每一个问相似问题的人, 而且比正确答案来得更快,因为它跳过了本来能发现这个错误的那些工作。

不会报错,日志里也什么都没有。系统看上去表现得格外好。

所以在本项目里,写入是设了闸门的:只有当质量评分器判定通过, 并且 有据性(groundedness)检查也没有失败时,答案才会被存下来。低置信度的答案仍然会带着它的警告送到用户面前, 但绝不会被写进记忆。这次写入还发生在追加警告文字 之前 ,所以被缓存下来的是干净的答案,而不是一段与下一位用户的问题毫无关系、 早已过时的验证提示。

总结成一条规则: 读路径要乐观,写路径要悲观。 缓存未命中的代价是几秒钟。缓存被投毒的代价, 是关于一家上市公司的错误数字被反复给出,直到有人发现为止。

在本项目中 · 四层,顺序固定

每一次请求在做任何检索之前,都会依次穿过这四层,而这个顺序是承重的,不是随手排的。

顺序层位置关键数字
始终开启 持久化会话状态 —— 每一步执行完都写入完整状态,以会话线程为键 graph.py · PostgreSQL 连接池上限 10
第 1 历史压缩 —— 把较早的消息做成摘要,最近的几条原样保留 nodes/compress_context.py 10 条消息触发,保留 4 条
第 2 实体延续 —— 从最近的消息里补上缺失的公司或年份 nodes/memory.py 扫描 6 条消息
第 3 语义答案缓存 —— 针对过往问题的两档查找 memory/semantic.py 0.95 命中 · 0.80 参考

为什么是这个顺序

先压缩 ,这样实体扫描读到的是一段干净、有界的历史,而不是五十条消息。 再补全 ,这样缓存查找用的是补全之后的问题 —— 拿「那他们的分红呢?」去查,什么有用的东西都匹配不到,而「dividends [BHP, FY2024]」则有可能匹配上。 最后查缓存 ,这样一旦命中就可以跳过下游的所有步骤 —— 而这正是设置缓存的全部意义。

有一个细节值得真正弄明白

缓存把 问题 存为可检索的文本,把答案、涉及的公司、年份和来源列表作为元数据放在它旁边。 这听上去是反的 —— 明明答案才是有价值的那一部分 —— 但在查找的那一刻,你手上只有一个问题, 所以把答案当作检索键,就意味着拿一个问题去和一个答案作比较。

这是两种形状完全不同的文本。问题很短,是疑问句;答案很长,充满数字和各种限定条件。 即使两者本来就是配对的,它们的向量(embedding)也落在不同的区域,于是相似度分数变得嘈杂, 你选的任何阈值都失去了意义。把问题存下来,就把它变成了问题对问题的比较 —— 而这正是你真正想要的那种比较。

这套记忆系统没有做到的事 没有过期,也没有失效机制 —— 如果报告被重新导入,基于旧版本生成的缓存答案依然在线有效。 没有按用户划分的命名空间,所以所有条目是全局可读的;在这里无害,因为不涉及客户数据, 但在任何一个真实的金融产品里,这都是拦路的问题。而且两个阈值都是凭判断定下来的, 没有在带标注的问题对上拟合过,所以并没有实测的误命中率。

2 · 上下文管理

决定某一次具体的模型调用里放什么。也常被称为上下文工程(context engineering)。

概念本身 · 人们默认它很简单的那一部分

假设记忆的问题已经解决了:你手上有对话历史、一批检索到的文档、一个缓存答案, 以及更早几轮的摘要。现在你要发起一次模型调用。你往里放什么?

朴素的答案是 全都放进去,而它之所以朴素,有一个值得说准确的理由。 问题不只是你可能超出上限,而是无关内容会实实在在地拉低答案质量。给模型八篇文档、 其中只有两篇相关,它就得先自己判断是哪两篇;只把那两篇单独给它,它就不用判断。 多余的上下文即使塞得下,也不是免费的。

所以上下文管理是一种 组装,而不是拼接。而真正让它成为一门功课的, 是这个决定要按 每一次调用来做,而不是按应用来做 —— 同一条流水线里的不同步骤,想要的上下文完全不同。

把上下文当成拼接

搭出一大块上下文,然后原封不动地传给每一个步骤。做法简单, 但每个步骤都要为自己不需要的文本付费,而真正重要的部分被稀释了。 一个路由决策,并不会因为看过八段年报切块就变得更好。

把上下文当成组装

每一次调用都拿到一份为它专门搭好的输入:只包含这一步需要的字段, 按这一步需要的精细度,以刻意安排的顺序排列,并且把不可信内容清楚地围起来。 代码更多,但每一次调用都更锐利。

它和检索有什么不同 —— 两者是邻居,不是同一份工作

检索负责找出候选文档。上下文管理负责决定哪些候选真的进入这次调用、 以什么形式进入、以及在输入里坐在什么位置。

在失败发生的那一刻,这条界线就非常明显了。如果正确的文档从来就没被找到, 那是检索的问题 —— 再聪明的组装也救不回来。如果正确的文档被找到了、排在第四位, 然后被截断掉,或者被埋在一段长输入的中间、被模型低估了权重,那就是检索完成了自己的工作, 而上下文组装把答案弄丢了。

两者也需要分开衡量,这正是本项目单独测量检索的原因 —— 包含已知数字的那篇文档到底有没有被取回来、排在第几名 —— 而不是从最终答案去反推检索质量。

五项工作 · 上下文管理到底由哪些部分组成

1
筛选 —— 什么才和 这一步 相关

能用的最窄输入,通常就是对的输入。分类器需要问题和它的规则; 校验器需要草稿和证据。把其中一个的上下文给另一个,只会让它变差,而不是让它更见多识广。

2
排序 —— 位置会改变行为

在一段长输入里,注意力并不是均匀分布的;开头和结尾被用上的可靠程度高于中间。 所以顺序是一个真实的决策:指令和当前问题放在两端,大块证据放在中间。

3
压缩 —— 有意丢掉细节

把十条消息摘要成两句话,是刻意的有损处理。这意味着摘要指令必须点名要保留什么, 否则模型会替你做决定,而丢掉的正是你需要的那一部分。

4
隔离 —— 按信任级别把内容围起来

你自己的指令,和从第三方 PDF 里抽出来的文本,最终会落在同一份输入里。 模型天生并不知道哪一份才具有权威性。给不可信的那一部分打上标签、围上边界, 才能阻止一篇文档被当成指令来执行 —— 这个风险叫做 提示词注入(prompt injection).

5
预算 —— 一份明确写下来的优先级顺序

空间不够时,总得砍掉一些东西。提前把这个顺序定下来 —— 并且写下来 —— 就是「一个被设计过的系统」和「代码里最后追加的那段恰好胜出的系统」之间的区别。

在本项目中 · 九次调用,九份不同的上下文

如果有人问上下文工程在实践中长什么样,这就是我会指给他看的那一部分。 一次请求最多会涉及九次模型调用,而且 没有任何两次收到相同的上下文。 每一次拿到的,都是刚好够它完成本职工作的最窄输入。

先讲三个贯穿全局的决定,因为它们能解释下面那张表:

  • 刻意分成两个模型客户端。 utils/llm.py 的输出上限是 1,000 token,用于路由、评分和改写。 utils/llm_large.py 允许 5,000 token,只用于综合(synthesis)和最终答案。 这样一来,路由决策就不会不小心按综合调用的预算来配。两者的 temperature 都设为 0.
  • 不同的工作用不同的精细度。 相关性评分看到的每一篇文档,都被截断到 1,200 个字符 —— 足够判断主题,而且在二十个候选上跑一遍也便宜。 综合步骤看到的是完整内容,因为一个数字可能出现在表格的任何位置。 同样的文档,两种精细度,按用途分别选择。
  • 结构是加上去的,不是指望它自己出现的。 检索到的文档会被转换成普通字典,把公司、年份、页码和来源类型提升为顶层键, 于是每个步骤都能一致地把表头格式化成 [3] FMG FY2024, p.39 [table] 这样的形式 —— 而且模型不需要靠推断,就能把一条结论归属到具体的公司。

每一次模型调用,以及它拿到了什么

调用收到刻意不给为什么
compress_context 较早的消息,按角色格式化,外加一条点名要保留什么的指令: 公司、指标、财年、结论 文档、检索状态 压缩是有损的,所以指令必须点名哪些内容要活下来
retrieve_decision 路由规则、七个示范例子,以及仅仅是补全之后的那个问题 对话历史;任何文档 它只是一个分类器。加上历史只会增加成本和漂移, 并不会让它返回的那四个布尔值更准
query_rewrite 改写规则、五个例子、问题本身 历史;文档 把一个问题变成检索关键词,需要的是这个问题本身,而不是过去
grade_docs 改写后的查询,加上每一个候选文档,每个都被截断到 1,200 字符,并标注序号、公司、年份、页码和表格标记 完整的文档全文;草稿答案 判断相关性需要的是覆盖所有候选的广度,而不是每一个候选内部的深度
synthesize 打过分的文档,以 完整 长度呈现,带编号的公司标题,加上问题本身,以及任何被明确标注「仅供参考」的缓存答案 被淘汰的候选文档;新闻;对话历史 起草答案需要完整的证据,而这个标注可以避免参考材料被当成信息来源
grade_answer 问题、打过分的文档(其中表格 注入了单位标注 ),以及草稿答案 对话历史;新闻;被淘汰的候选文档 校验必须把草稿和它据以写成的那份证据逐一比对 —— 不多也不少
aggregate → calc? 问题,以及合并后的上下文 原始文档 是否需要做算术,取决于实际检索回来的数字,而不是问题的措辞方式
计算器智能体 问题、合并后的上下文,以及它自己那份不断增长的工具调用记录 原始文档;对话历史 按照指令,它只对上下文中已经出现的数字做运算
answer 带分区标记的合并上下文,加上一段在运行时才拼装出来的系统提示词:先是基础规则,然后是包含编号来源列表的引用区块,再是图表区块 —— 每一块都只在适用时才被加进去 原始文档;被淘汰的候选文档 指令集合与实际可用的材料保持一致,所以模型永远不会被要求去引用并不存在的来源

隔离,具体怎么做

不同可信级别的内容是用可见的标记围起来的,而不是混在一起。合并后的上下文使用方括号标题 —— 【Annual Report Data】, 【Recent News】, 【Calculation Result】 —— 并且最终的提示词会指示模型优先采信年报数据,如果各个分区之间互相矛盾,就把这个差异指出来。缓存答案会带上「仅供参考」。每一份文档都带着自己的公司标题,这样一个数字就不会在公司之间串位。

这里仍然薄弱的地方 靠约定来围栏并不等于强制执行。目前没有任何环节会扫描摄入的 PDF 文本、检查里面是否有读起来像指令的内容,所以检索回来的文本是被当作可信输入,直接和我自己写的指令并排放在一起的。对于公开发布的年报来说这个风险很低;但对于任何用户可以自行上传的文档来说,这是不可接受的。另外,整个系统里没有任何地方做 token 计量 —— 上面描述的那套优先级顺序只是隐含在代码里,而不是一份明确的预算:在空间不够时按优先级把最低的那部分材料丢掉。

3 · 编排(orchestration)

决定哪些步骤要跑、以什么顺序跑,以及由谁来做这个决定。

这个概念 · 两个朴素版本,朝相反的方向失败

像「比较 BHP 和 Rio 在 FY2024 的营收增长」这样的问题,不可能靠一次模型调用完成。它需要:弄清楚问题到底在问什么,把它转成搜索词,分别搜两家公司,判断搜回来的东西,起草,校验,算出一个增长率,再带着引用格式化输出。多步骤的工作需要有东西来协调这些步骤 —— 这就是编排(orchestration)。

有两种显而易见的做法,而它们会朝相反的方向失败。

朴素版本 A —— 一个巨大的提示词

把所有指令塞进一次调用,然后祈祷。没有分支,所以一个含糊的问题和一个精确的问题走的是同一条路。没有重试,因为根本不存在可以拿来重试的「某一步」。而当答案错了的时候,你没法知道是推理的哪一部分出了问题 —— 因为它根本就没有「部分」。

朴素版本 B —— 完全自主的循环

把工具交给模型,让它自己反复决定一切,直到它自己停下来。灵活性拉满,同时:成本没有上限,每次运行走的路都不一样、因此什么都无法复现,没有办法对某个决策做单元测试,而且每次回头查同一次事故,执行轨迹都长得不一样。

真实系统落在这两者之间,而且 这个选择是按组件做的,不是按整个应用做的。 这是本节里最有用的一个想法。顺序你已经知道的步骤,放进固定的图里;顺序确实取决于中间结果的步骤,才给它一个有界的循环。

这条光谱 · 由谁来决定的三个层级

1
固定链条 —— 一切由代码决定

先 A,再 B,再 C,永远如此。完全可预测,测试起来毫不费力,同时完全没有适应能力。只要你确实知道顺序,它就是对的选择。

2
带条件边的图 —— 由代码决定,但用的是模型的输出

每个步骤是一个节点;节点之间走哪条连接,是在运行时由读取状态的普通函数决定的。被读取的那个值可能是模型 生成 的,但分支是由普通代码来选的。

对大多数生产环境的工作来说,这是最佳平衡点,而其中的原因值得明说: 模型贡献的是判断,代码保留的是对流程的控制权。 你既拿到了适应能力,路由又保持可读、可测试,而且对同一个状态每次都给出完全相同的结果。

3
智能体循环 —— 由模型决定,但有限制

模型反复地挑一个工具、看结果、再挑下一个,直到完成 —— 通常就是所谓的 ReAct 循环。只有当步骤的数量和顺序确实无法事先知道时,这才是对的选择。它永远需要一个迭代次数上限。

构件 · 读懂代码所需要的词汇

  • 状态(state)。 一个带类型的对象,每个节点都从它读、也往它写。节点只返回自己改动过的字段,由框架负责把这些改动合并进去 —— 所以一个节点不会意外覆盖掉它根本没碰过的字段。
  • 节点(node)。 一个步骤。就是一个普通函数:输入状态,输出一份局部更新。
  • 边与条件边。 一条固定的连接,或者一个读取状态、返回下一个节点是谁的函数。
  • 归并器(reducer)。 当并行分支同时写入同一个字段时,用来合并这些写入的规则。默认行为是覆盖 —— 在分支并发运行时这会悄悄丢数据,所以列表通常需要改成追加,字典需要改成合并。
  • 扇出与扇入(fan-out / fan-in)。 把一个请求拆成多个并行分支,然后再合并回来。和 map-reduce 是同一个形状。
  • 子图(subgraph)。 把一整张图当作更大的图里的一个节点来用,它有自己私有的状态 —— 所以它的内部细节不会渗进父图。
  • 检查点与中断(checkpoint / interrupt)。 在每一步之后把状态持久化下来,正是这一点让「跑到一半停下、等人处理、再原地续跑」成为可能。
  • 主管模式(supervisor pattern)。 一张顶层的图,负责把任务分派给各个专职子智能体并合并它们的结果,而不是让一个智能体包办所有事情。

在这个项目里 · 三个层级同时存在

整个架构是一张由 11 个节点组成的主管图,其中包含一个由 5 个节点组成的检索子图,而这个子图里又包含一个专门做算术的 ReAct 智能体。所以光谱上的三个点在同一个系统里全都出现了,每一个都是出于不同的理由被选中的。

层级在哪里为什么在那里
固定链条 compress_context → memory;以及每一条路径都先经过 guardrails ,然后才到 answer 这个顺序确实是已知的,而且不允许变化。把护栏放在边界上,意味着将来新增的分支会自动继承它
条件图 四个路由函数,位于 graph.py —— 分别在 memory 之后、路由决策之后、聚合之后、护栏之后 走哪条路取决于问题本身,但这个决定必须可复现、可测试。每一个都是读取状态的普通 Python 代码
智能体循环 agents/calculator_agent.py 一个问题需要多少步算术 —— 先统一单位,再相减,再相除 —— 确实无法事先知道

让这一切成立的那道分离

做路由的那个模型并不选择下一个节点。它返回的是四个布尔值 —— is_out_of_scope, needs_retrieval, needs_news, needs_clarification —— 写进状态里。然后由一个普通函数读取它们,决定走哪个分支。

这道分离就是整个设计的要害。模型贡献的是它擅长的那种判断,也就是理解一个问题到底在问什么。代码保留对流程的控制权,这意味着路由就是一个你可以拿一个字典、完全不联网就能做单元测试的函数 —— 而且在 tests/test_graph_routing.py 里就有正是这么做的测试。

并行扇出,以及为什么它需要显式的合并规则

一个比较类的问题会被拆解成「每家公司一次搜索」,图会为每家公司派出一条检索分支并发执行。接下来有两件事必须显式写明,因为默认行为在并发场景下是错的:

  • 检索到的文档是累加的 ,用的是列表拼接而不是覆盖,所以五条分支产出的是一个合并后的文档池,而不是只剩最后一条分支的结果。
  • 每家公司的「是否找到」标记按字典合并 ,靠的是一个很小的自定义归并器,这样每条分支只写自己的键,不会抹掉别人的。如果用默认的覆盖行为,最后跑完的那条分支会把其他所有公司的状态都删掉 —— 这种 bug 只会在并发下出现,而且只是偶尔出现。

重试的时候,扇出天然只会针对那些仍然缺失的公司,因为查询改写这一步已经把查询列表收窄了。

有界循环

检索子图是可以循环的:如果打过分的文档不可用,或者草稿失败了,或者有两条及以上的断言没能通过校验,它就回到查询改写那一步。有三条约束让这件事保持安全。状态里的一个计数器把它限制在 2 次尝试以内。每次重试都必须改变自己的输入 —— 校验失败的那些断言标签会变成额外的搜索词 —— 因为输入完全相同的重试只是在烧钱。而且重试次数耗尽是一个有定义的结果,会路由到兜底回复,而不是抛出错误。

两个容易被忽略的小决定

  • 子图声明了一份输出 schema, 所以只有最终完成的 retrieval_result 才会回流到父状态里。它内部那十几个工作字段 —— 重试计数、还没打分的候选、每家公司的查询 —— 都保持私有。这是把状态隔离当成一种设计选择,而不是一种命名约定。
  • 澄清时的暂停是持久的。 clarify 节点会中断执行,把问题返回给调用方;API 拿到用户的回复后,在同一个线程上恢复执行,节点会把回复合并进原来的问题里,然后重新做一次路由。它之所以能成立,完全是因为每一步之后状态就已经被写进检查点了 —— 所谓暂停,不过就是「没有下一步」而已。如果没有持久化,一旦进程重启,「问用户一句话」就是一条死路。
这里仍然薄弱的地方 路由完全依赖一次模型调用,背后没有确定性的兜底,所以一次分类错误是无人看守的 —— 它会安全地退化成默认走检索,但没有任何东西会发现这个错误。系统里也没有按请求计的成本上限或步数上限,所以一条异常昂贵的路径是可能发生的,而且不会触发任何告警。另外,节点级的失败是各自被吞掉的,而不是汇总到一个地方统一上报,这意味着一个持续失败的步骤会悄无声息地拉低质量。

一个请求,从头到尾

同一个问题被追踪着走完全部十一个步骤,并在每一处标出三个主题中是哪一个在干活。如果这三者在你脑子里还是糊在一起的,就重读这一节。

这个问题 第一轮:「BHP 的 FY2024 营收是多少?」—— 正常回答。第二轮: 「那他们的分红呢?」 —— 下面追踪的就是这一轮。
步骤发生了什么谁在干活
0 · load 线程状态从 PostgreSQL 里读回来,包括第一轮的消息 记忆 —— 会话持久化
1 · compress_context 只有四条消息,低于十条的阈值,所以什么都不压缩 上下文 —— 压缩环节,选择不动手
2 · memory 问题里既没有公司也没有年份。往回扫六条消息,找到了 BHP 和 FY2024,于是查询变成 What about their dividends? [BHP, FY2024] 记忆 —— 实体延续
3 · memory(缓存) 补全后的查询被转成向量,再和过去的问题比对。最佳匹配得分 0.71 —— 低于两个档位的门槛,所以既没有命中,也没有拿到种子 记忆 —— 语义缓存
4 · route 一个普通函数读取 cache_hit。值为 False,所以继续走到路由决策,而不是直接跳到答案 编排 —— 条件边
5 · retrieve_decision 模型收到的是规则、七个示例,以及补全后的问题,仅此 而已。返回 needs_retrieval 为 true,其余全部为 false 上下文 —— 选材 · 编排 —— 模型做判断,不做路由
6 · route 代码读取那四个布尔值,向检索子图派出一条并行分支 编排 —— 扇出
7 · query_rewrite 变成 BHP FY2024 dividends,公司列表为 ["BHP"],并且每家公司的「是否找到」标记被初始化为 false 上下文 —— 为检索重塑输入
8 · retrieve_company 关键词检索和向量检索各自从 BHP 自己的索引里返回 20 个候选;融合之后,再由一个真正去读文本的模型重排,压到 8 个 检索 —— 而每家公司的分支由编排来掌管
9 · grade_docs 每个候选被截断到 1,200 字符再送去判断。不相关的被丢掉;因为确实存在打过分的文档,「是否找到」标记被修正为 true 上下文 —— 保真度取舍 · 护栏 —— 相关性过滤
10 · synthesize 存活下来的文档以完整长度呈现,带编号的公司标题。草稿写出来时带着 [N] 标记 上下文 —— 以完整保真度组装
11 · grade_answer 草稿加上同一批文档,这次表格里注入了单位标注。没有任何无依据的断言,所以 grounded = yes,confidence = high 上下文 —— 注入标注 · 护栏 —— 有据性(groundedness)
12 · aggregate 草稿被包进 【Annual Report Data】之下。一次很窄的模型调用判定不需要做算术 上下文 —— 围栏 · 编排 —— 分支数据
13 · guardrails → answer 确定性的长度检查通过。最终提示词带着引用区块和它的编号来源列表被组装出来;随后没有被引用到的来源会被移除,标记重新编号 编排 —— 边界关卡 · 上下文 —— 运行时提示词组装
14 · save 评分通过,且有据性不是「no」,所以干净的答案被写进缓存 —— 而且是在附加置信度提示之前写的。状态被写入检查点 记忆 —— 带闸门的写入,由评估器来决定
这条轨迹说明了什么 这三者是在每一步交织在一起的,而不是分层堆叠的。记忆在第 2 步补上了 BHP 和 FY2024,这改变了第 5 步组装出什么样的上下文,而这又改变了第 6 步编排走哪一条分支 —— 到了第 14 步,质量检查又决定了记忆被允许留下什么。一旦这些边界划错了,失败就会互相串味:一个上下文的 bug 看起来像记忆的 bug,一个编排的 bug 看起来像模型不行。

术语表

上文用到的每一个术语,用大白话解释。

展开全部定义 —— 26 个术语
无状态(stateless)
模型在两次调用之间什么都不保留。你看到的每一处「记忆」,其实都是外面那个程序把之前的文本重新发了一遍。
Token
模型阅读和计费所用的单位 —— 非常粗略地说,大约是四分之三个单词。各种限制和价格都是按 token 计的,不是按字符。
上下文窗口(context window)
一次调用中模型能读到的 token 上限。这是一道硬天花板,而且窗口里的所有内容在每一次调用时都会被重新计费。
提示词(prompt)
发送给模型的文本。通常拆成系统提示词(指令和规则)和用户消息(真正的输入)两部分。
工作状态(working state)
在单次请求的各个步骤之间传递的数据。它是「局部变量也算记忆」这个意义上的记忆 —— 不应该活得比这次请求更久。
检查点(checkpointing)
在每一步之后把完整状态写进持久化存储,这样一次运行能挺过重启,事后也能被精确重放。
线程(thread)
一段对话,用一个 id 来标识。它的所有检查点共享这个 id。
情景记忆 / 语义记忆 / 程序性记忆
过去发生的事件、通用的事实,以及学会的操作流程。这是一套从认知科学借来的、很好用的分类;大多数系统只实现了其中一两种。
向量(embedding)
一串代表某段文本含义的数字。含义相近的文本,得到的数字串在数值上也彼此靠近,正是这一点让基于含义的搜索成为可能。
向量库(vector store)
一种能快速找出与查询向量最接近的那些向量的存储。
语义缓存(semantic cache)
当新问题的 含义 和旧问题一样时,直接复用过去的答案;这里的判断依据是向量相似度,而不是文本完全一致。
TTL —— 存活时间(time to live)
一个存储项在必须被丢弃或重新计算之前,还能保持有效多久。这个项目的缓存里没有它,这是一个实打实的缺口。
缓存投毒(cache poisoning)
一条错误的条目被写了进去,然后被自信地、反复地端出来。这正是为什么一个存放你自己输出的缓存,需要一条带闸门的写入路径。
摘要缓冲(summary buffer)
把最近的消息原样保留,把更早的消息换成生成出来的摘要。它保住了那些指代性的追问,而单纯的滑动窗口会把这些线索毁掉。
指代(coreference)
一个指向前文某个已命名事物的说法 —— 「他们的分红」「那一个」。搜索没法解析它,因为那个实体根本没出现在文本里。
上下文工程(context engineering)
有意识地决定每一次模型调用里放什么、按什么顺序放、以什么保真度放。提示词工程只是其中的一部分。
中间遗失(lost in the middle)
一个被观察到的现象:对于一段很长的输入,模型使用开头和结尾的可靠程度高于中间部分。这正是为什么排序是一个设计决策。
提示词注入(prompt injection)
检索回来的内容里,那些被模型当成指令读进去的文本。它之所以要紧,是因为检索到的文档和你自己的指令一起坐在同一份输入里。
编排(orchestration)
把多次模型调用和多个工具协调成一条流程:哪一步跑、按什么顺序跑、失败了怎么重试。
节点 / 边 / 条件边
一个步骤;步骤之间一条固定的连接;以及一个读取状态、决定下一步走哪里的函数。
归并器(reducer)
当并行分支同时写入同一个状态字段时,用来合并这些写入的规则。默认的覆盖行为会在并发下悄悄丢数据。
扇出 / 扇入(fan-out / fan-in)
把一个请求拆成多个并行分支,再把结果合并回来。和 map-reduce 是同一个形状。
子图(subgraph)
把一整张图当作更大的图里的一个节点来用,它有自己私有的状态。
中断 / 恢复(interrupt / resume)
暂停执行以等待人工介入,之后从同一个点继续往下跑。它之所以是持久的,只因为状态早已被写入检查点。
ReAct 循环
模型在推理和调用工具之间交替进行,把每次的结果再喂回去,直到得出答案。它需要一个迭代次数上限。
主管模式(supervisor pattern)
一张顶层的图,负责把任务分派给专职子智能体并合并它们的输出,而不是让一个智能体包办一切。