先讲概念,再看代码
记忆、上下文管理、编排
这三个术语常被当成同义词换着用,但它们并不能互相替代。 本页从每一个术语所要解决的问题出发,把它一层层搭起来,然后精确指出它在 Mines Financial 代码库中的位置 —— 逐个文件、逐个数字地对照。
从这里开始 · 制造出全部三个问题的两个事实
本页所有内容,都是从语言模型的两个属性推导出来的。这两点都不是 bug, 也都不是换一个更好的模型就能解决的。
第一:模型是无状态的。 当你的程序调用模型时,它发过去一段文本, 再拿回一段文本。模型什么都不会留下。下一次调用对上一次调用一无所知。当 ChatGPT 看起来记得你之前说过的话时,那并不是模型在记忆 —— 而是应用程序在你看不见的地方, 把整段对话当作新请求的一部分重新发了一遍。所有看起来像记忆的东西, 都是外面那层程序自己搭出来的。
第二:模型的输入长度有固定上限。 它在一次调用中能读进去的文本量存在一个最大值, 这个上限叫做 上下文窗口(context window)。 而且因为模型什么都不保留,你希望它考虑的全部内容,必须在 每一次 调用里都塞进这个上限之内,而且每一次你都要为这全部内容付费。
把这两点放在一起,就自然掉出三个彼此独立的工程问题 —— 也正是本页的三个主题:
记忆系统
模型什么都不保留,那么 我们要在模型之外存什么,存在哪里? 这是一个存储与生命周期的问题。
上下文管理
窗口既有限又昂贵,那么 在我们已经存下来的所有东西里,哪些要放进这一次调用? 这是一个筛选与组装的问题。
编排
单次调用无法可靠地完成多步骤的工作,那么 要跑哪一串调用,又由谁来决定? 这是一个控制流的问题。
区分这三者 · 大多数讲解都会跳过的那条界线
这三者在实践中确实互相重叠,所以人们容易把它们混为一谈。要把它们分清楚, 最干净的办法是去问每一个各自 拥有什么.
| 拥有什么 | 时间尺度 | 失败方式 | |
|---|---|---|---|
| 记忆 | 我们保留什么,保留多久 | 跨调用、跨会话、跨数周 | 记住了错误的东西,或者永远记着不放 |
| 上下文管理 | 在某一次具体调用里发送什么 | 一次调用 | 发得太多、发得太少,或者顺序不对 |
| 编排 | 哪些调用会发生,以什么顺序发生 | 一次请求,从开始到结束 | 走错分支、无限循环、无法观测的失败 |
下面是一个同时用到三者的具体例子。用户问「那他们的分红呢?」。
- 记忆 提供了「上一个问题问的是 BHP 和 FY2024」这个事实。 这个事实是更早的时候存下来的,现在被读了出来。
- 上下文管理 决定路由模型只拿到补全后的问题和它自己的指令 —— 不给对话历史,也不给检索到的文档,因为那个模型唯一的任务就是分类。
- 编排 决定:因为这个问题需要报告数据,所以检索子图要在回答步骤之前运行; 而如果缓存中已经命中了一个答案,回答步骤就整个跳过。
1 · 记忆系统
在模型之外的存储,并且带有明确的生命周期。
概念本身 · 从最笨的版本讲起
对付无状态最朴素的做法很显然,而且几乎每个项目都是从这里起步的: 把整段对话放进一个列表,每次都把整个列表发过去。 这在一段时间内确实管用,而且它就是正确的第一个版本。
随着对话变长,它会分三个阶段崩掉。
- 第一,成本。 如果第二十轮要把前面十九轮全部重发一遍, 你就为同一段文本付了二十次钱。成本是随对话长度的平方增长的,而不是线性增长。
- 第二,质量 —— 这一点常常出乎人们意料。 模型对输入开头和结尾的注意力最稳定。 一个埋在超长输入正中间的关键数字,被用上的概率明显低于同一个数字出现在一段短输入里的情况。 所以更长的上下文,反而可能产出更 糟糕 的答案。
- 最后,硬性失败。 输入最终会超出窗口,调用直接报错。
所以,当「全都发过去」不再奏效时,你要动手搭的东西就是记忆系统。而最核心的一点认识是: 「记忆」并不是一个东西。它其实是四种共用同一个名字的不同需求, 各自有不同的存储、不同的生命周期,出错时也有不同的后果。把它们当成一个组件来设计, 是最常见的错误。
四种记忆 · 各自是干什么用的
工作状态 —— 单次请求内部的记忆
在一次请求被处理的过程中,各个步骤之间需要互相传递东西:改写后的查询、 检索到的文档、答案草稿、各项判定结果。这是「局部变量也算一种记忆」意义上的记忆。 它只为一次请求而存在,请求结束之后就应该消失。
它经常和其他几种记忆被搞混,而且是双向搞混:有人把需要长期存活的事实存在这里, 请求一结束就丢了;也有人把只属于单次请求的临时数据塞进数据库,然后永远不清理。
MainState 类型化字典,定义在 state.py ——
大约二十个字段,包括 query,
aggregated_context, needs_retrieval,
guardrails_passed, confidence。检索子图有它自己独立的 RetrievalState,所以它内部的工作字段永远不会泄漏到父图里。
会话持久化 —— 熬过一次重启
一段对话必须比处理它的那个进程活得更久。服务器重启、发布上线、 用户明天再回来 —— 这个会话线程都必须还在。所以工作状态必须写到某个持久化的地方。
解决这件事的模式叫做 检查点(checkpointing)。 它换来的不只是持久化,而且往往后一半价值更大:因为每一个中间状态都被写了下来, 事后很久你仍然可以精确重建出系统在每一步看到的东西。
graph.py 在一个连接池之上接了一个 PostgreSQL 检查点器(最大连接数 10,开启自动提交)。每个节点执行完之后都会写入完整状态,
以会话线程 id 作为键。这也正是澄清式暂停之所以能实现的原因 —— 见下文的编排部分。
长期记忆 —— 比会话活得更久的事实
那些下周还应该知道的东西:用户偏好、正在讨论的实体、之前已经得出的结论。 智能体(agent)相关的文献通常把这一类再分成三种 —— 情景记忆、语义记忆和程序性记忆 —— 但工程上的问题要简单得多:键是什么,以及什么条件会让这条记录失效?
这一类的失败模式是最糟糕的,因为一条错误的长期记忆会被以十足的自信讲出来, 而且永远不会被重新检查。
nodes/memory.py —— 再加上下面那个答案缓存,
它的存活时间很长,但键是问题本身,而不是用户。
缓存 —— 不重复已经做过的工作
严格来说这是一种优化,而不是记忆,但它会被存下来、之后会被读回来、 而且可能是错的 —— 所以它的行为方式和记忆一样,也需要同样的纪律。
普通缓存匹配的是完全相同的文本,这对问题来说毫无用处:「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)。
概念本身 · 人们默认它很简单的那一部分
假设记忆的问题已经解决了:你手上有对话历史、一批检索到的文档、一个缓存答案, 以及更早几轮的摘要。现在你要发起一次模型调用。你往里放什么?
朴素的答案是 全都放进去,而它之所以朴素,有一个值得说准确的理由。 问题不只是你可能超出上限,而是无关内容会实实在在地拉低答案质量。给模型八篇文档、 其中只有两篇相关,它就得先自己判断是哪两篇;只把那两篇单独给它,它就不用判断。 多余的上下文即使塞得下,也不是免费的。
所以上下文管理是一种 组装,而不是拼接。而真正让它成为一门功课的, 是这个决定要按 每一次调用来做,而不是按应用来做 —— 同一条流水线里的不同步骤,想要的上下文完全不同。
把上下文当成拼接
搭出一大块上下文,然后原封不动地传给每一个步骤。做法简单, 但每个步骤都要为自己不需要的文本付费,而真正重要的部分被稀释了。 一个路由决策,并不会因为看过八段年报切块就变得更好。
把上下文当成组装
每一次调用都拿到一份为它专门搭好的输入:只包含这一步需要的字段, 按这一步需要的精细度,以刻意安排的顺序排列,并且把不可信内容清楚地围起来。 代码更多,但每一次调用都更锐利。
它和检索有什么不同 —— 两者是邻居,不是同一份工作
检索负责找出候选文档。上下文管理负责决定哪些候选真的进入这次调用、 以什么形式进入、以及在输入里坐在什么位置。
在失败发生的那一刻,这条界线就非常明显了。如果正确的文档从来就没被找到, 那是检索的问题 —— 再聪明的组装也救不回来。如果正确的文档被找到了、排在第四位, 然后被截断掉,或者被埋在一段长输入的中间、被模型低估了权重,那就是检索完成了自己的工作, 而上下文组装把答案弄丢了。
两者也需要分开衡量,这正是本项目单独测量检索的原因 —— 包含已知数字的那篇文档到底有没有被取回来、排在第几名 —— 而不是从最终答案去反推检索质量。
五项工作 · 上下文管理到底由哪些部分组成
筛选 —— 什么才和 这一步 相关
能用的最窄输入,通常就是对的输入。分类器需要问题和它的规则; 校验器需要草稿和证据。把其中一个的上下文给另一个,只会让它变差,而不是让它更见多识广。
排序 —— 位置会改变行为
在一段长输入里,注意力并不是均匀分布的;开头和结尾被用上的可靠程度高于中间。 所以顺序是一个真实的决策:指令和当前问题放在两端,大块证据放在中间。
压缩 —— 有意丢掉细节
把十条消息摘要成两句话,是刻意的有损处理。这意味着摘要指令必须点名要保留什么, 否则模型会替你做决定,而丢掉的正是你需要的那一部分。
隔离 —— 按信任级别把内容围起来
你自己的指令,和从第三方 PDF 里抽出来的文本,最终会落在同一份输入里。 模型天生并不知道哪一份才具有权威性。给不可信的那一部分打上标签、围上边界, 才能阻止一篇文档被当成指令来执行 —— 这个风险叫做 提示词注入(prompt injection).
预算 —— 一份明确写下来的优先级顺序
空间不够时,总得砍掉一些东西。提前把这个顺序定下来 —— 并且写下来 —— 就是「一个被设计过的系统」和「代码里最后追加的那段恰好胜出的系统」之间的区别。
在本项目中 · 九次调用,九份不同的上下文
如果有人问上下文工程在实践中长什么样,这就是我会指给他看的那一部分。 一次请求最多会涉及九次模型调用,而且 没有任何两次收到相同的上下文。 每一次拿到的,都是刚好够它完成本职工作的最窄输入。
先讲三个贯穿全局的决定,因为它们能解释下面那张表:
- 刻意分成两个模型客户端。
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】 —— 并且最终的提示词会指示模型优先采信年报数据,如果各个分区之间互相矛盾,就把这个差异指出来。缓存答案会带上「仅供参考」。每一份文档都带着自己的公司标题,这样一个数字就不会在公司之间串位。
3 · 编排(orchestration)
决定哪些步骤要跑、以什么顺序跑,以及由谁来做这个决定。
这个概念 · 两个朴素版本,朝相反的方向失败
像「比较 BHP 和 Rio 在 FY2024 的营收增长」这样的问题,不可能靠一次模型调用完成。它需要:弄清楚问题到底在问什么,把它转成搜索词,分别搜两家公司,判断搜回来的东西,起草,校验,算出一个增长率,再带着引用格式化输出。多步骤的工作需要有东西来协调这些步骤 —— 这就是编排(orchestration)。
有两种显而易见的做法,而它们会朝相反的方向失败。
朴素版本 A —— 一个巨大的提示词
把所有指令塞进一次调用,然后祈祷。没有分支,所以一个含糊的问题和一个精确的问题走的是同一条路。没有重试,因为根本不存在可以拿来重试的「某一步」。而当答案错了的时候,你没法知道是推理的哪一部分出了问题 —— 因为它根本就没有「部分」。
朴素版本 B —— 完全自主的循环
把工具交给模型,让它自己反复决定一切,直到它自己停下来。灵活性拉满,同时:成本没有上限,每次运行走的路都不一样、因此什么都无法复现,没有办法对某个决策做单元测试,而且每次回头查同一次事故,执行轨迹都长得不一样。
真实系统落在这两者之间,而且 这个选择是按组件做的,不是按整个应用做的。 这是本节里最有用的一个想法。顺序你已经知道的步骤,放进固定的图里;顺序确实取决于中间结果的步骤,才给它一个有界的循环。
这条光谱 · 由谁来决定的三个层级
固定链条 —— 一切由代码决定
先 A,再 B,再 C,永远如此。完全可预测,测试起来毫不费力,同时完全没有适应能力。只要你确实知道顺序,它就是对的选择。
带条件边的图 —— 由代码决定,但用的是模型的输出
每个步骤是一个节点;节点之间走哪条连接,是在运行时由读取状态的普通函数决定的。被读取的那个值可能是模型 生成 的,但分支是由普通代码来选的。
对大多数生产环境的工作来说,这是最佳平衡点,而其中的原因值得明说: 模型贡献的是判断,代码保留的是对流程的控制权。 你既拿到了适应能力,路由又保持可读、可测试,而且对同一个状态每次都给出完全相同的结果。
智能体循环 —— 由模型决定,但有限制
模型反复地挑一个工具、看结果、再挑下一个,直到完成 —— 通常就是所谓的 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 拿到用户的回复后,在同一个线程上恢复执行,节点会把回复合并进原来的问题里,然后重新做一次路由。它之所以能成立,完全是因为每一步之后状态就已经被写进检查点了 —— 所谓暂停,不过就是「没有下一步」而已。如果没有持久化,一旦进程重启,「问用户一句话」就是一条死路。
一个请求,从头到尾
同一个问题被追踪着走完全部十一个步骤,并在每一处标出三个主题中是哪一个在干活。如果这三者在你脑子里还是糊在一起的,就重读这一节。
| 步骤 | 发生了什么 | 谁在干活 |
|---|---|---|
| 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」,所以干净的答案被写进缓存 —— 而且是在附加置信度提示之前写的。状态被写入检查点 | 记忆 —— 带闸门的写入,由评估器来决定 |
术语表
上文用到的每一个术语,用大白话解释。
展开全部定义 —— 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)
- 一张顶层的图,负责把任务分派给专职子智能体并合并它们的输出,而不是让一个智能体包办一切。