跳到主要内容

内容阅读

Agent 越聊越贵:LangGraph 三套记忆的边界与 token 账单

Agent阿新聊ai

TL;DR: 「越聊越贵」的机制很朴素:checkpointer 让历史消息全部留存,而模型每次都看全量 messages,成本随轮数线性涨、响应随长度变慢。解法不是换更大数据库,是分清三套东西的边界:checkpointer 管短期状态,Store 管跨会话长期记忆,上下文工程管每次到底给模型看多少。

账单是怎么涨起来的

第 03 篇讲过:挂了 checkpointer 之后,thread 里的历史全部留存,后续每次 invoke 模型都能看到。这正是问题所在。做一个粗略测算(数字是假设,量级是真实的):一个客服 agent 平均一轮对话新增 2000 token,30 轮之后每轮请求携带约 60000 token。前 30 轮总共产生了约 93 万 token 的输入计费,其中大量是重复发送的旧历史。延迟同样在涨,首 token 时间随上下文长度明显变长。

把 93 万这个数怎么来的拆开,机制就完全透明了。第 1 轮模型看 2000 token,第 2 轮看 4000,第 3 轮看 6000,以此类推,第 30 轮看 60000。请求计费的是每次发送的全量输入,把 30 轮加总,就是 2000 乘以 30 乘以 31 除以 2,约 93 万。注意其中结构性的浪费:最后一轮的 60000 里有 58000 是前面轮次已经付过钱的内容,每一轮都在为同一段历史重复付费。存档本身不要钱,把存档重新发给模型才是成本,这句话是整篇的支点:数据库里存全量,模型面前做裁剪,两件事必须分开做。

这个支点也解释了为什么「换更大的数据库」「清更多历史」这类直觉方案都走偏。数据库从来不是瓶颈,历史才几百 KB,清历史则是在砍恢复能力和会话连续性,为了省重发把存档的价值也扔了。正确的目标函数是:存储侧什么都不丢,发送侧每次都刚好。这也是为什么解法注定是一层「发送策略」而不是一次「存储升级」。

延迟的曲线和账单不同,它不是线性的。上下文越长,模型处理输入的时间越长,首 token 时间随之变长,用户感知到的就是「这个客服越聊越慢」。对交互型应用,延迟往往比费用先成为投诉点。而且它藏得比费用深:费用在账单里,延迟散在每一次对话里,用户用脚投票之前,监控面板上未必看得到。

还有一个更隐蔽的代价:质量。长上下文里模型要找的信息被稀释了,研究报告和社区实践都观察到过「关键信息埋在长上下文中部时利用率下降」的现象。也就是说全量历史不只贵和慢,还可能让回答变差。这条是方向性结论,各家模型程度不同,但「上下文越长效果越好」在多轮对话场景基本不成立。

顺带回应一个常见反驳:主流模型厂商都有提示缓存,重复前缀的输入费用有折扣。缓存确实能缓解费用曲线,但它不改变另外两个问题:缓存命中率取决于前缀完全一致(往 messages 中部插入摘要会打断缓存),而且延迟和质量问题与计费无关。把缓存当成不裁剪上下文的理由,账单会好看一点,慢和糊一点都没少。

面对这条上涨曲线,可行路线其实有三条,值得先把另外两条排除掉。第一条,换更长上下文的模型:天花板确实高了,但单价通常也更高,成本结构不变,质量与延迟问题原样保留,等于把问题推后几轮再爆发。第二条,把历史 RAG 化:几百轮的超长会话里这是正解(检索相关片段注入,而不是全量携带),但工程复杂度上了一个台阶,嵌入式向量库加检索质量调优都来了。客服 agent 三五十轮的量级用不上它,上下文工程这一层就够。第三条就是本篇主线:让每次请求只携带该携带的内容。三条不互斥,绝大多数场景的性价比排序是三、二、一。

三套东西各管一段

社区里大量混淆来自把三个概念都叫「记忆」。先立正名,再谈用法:

名字 管什么 作用域 生产实现
checkpointer 执行状态与消息历史 一个 thread 内 Postgres
Store 跨会话的业务记忆 跨 thread Postgres
上下文工程 每次给模型看多少 每一次调用 代码策略
  1. checkpointer(短期记忆):thread 内的执行状态,作用域是一个业务流程。有了它,多轮对话和断点恢复才成立。它不该被清理得太积极,它是恢复的依据,清掉了它,第 03 篇的四件事全部作废。
  2. Store(长期记忆):跨 thread 的键值存储,按 namespace 组织,可以配 embedding 做语义检索。用户偏好、历史结论放这里。它是唯一跟着用户而不是跟着流程走的一层。
  3. 上下文工程(messages 管理):每次调用时决定给模型看多少。它不删数据库里的东西,只裁剪输入,所以它是策略而不是存储,随时可以改、可以回退。

一句话版:checkpointer 负责「不忘事」,Store 负责「跨会话记得」,上下文工程负责「每次只看该看的」。三者经常被指望互相替代,结果哪头都做不好。三个典型的错位:指望 checkpointer 当长期记忆,用户换个流程就「失忆」,因为它只管一个 thread;指望 Store 做断点恢复,流程跑到一半崩溃后续不上,因为它没有执行进度的概念;指望裁剪代替存储,想让模型「忘了」某段对话就把它从 messages 删掉,结果数据库里原样都在,下次恢复又回来了。每套东西答一个問題,答案不通用。

三套东西的生产细节各有一句要领。checkpointer 的清理节奏在第 03 篇讲过,这里补一句记忆视角的:它是恢复依据,清理窗口要避开还在进行中的流程,合规要求的保留期优先于省存储的冲动。Store 的要点是权限跟着主体走:namespace 里第一层级是用户或租户,读写校验就要在这一层做,跨主体的检索入口必须收口,不然长期记忆变成横向越权的数据源。上下文工程的要点是策略配置化:预算、阈值、保留策略全部进配置,不同业务线允许不同值,改策略不用发版,灰度和回退才有可能。

一轮请求的输入解剖

谈预算之前先把「一轮请求到底发了什么」拆开。挂了 checkpointer 的多轮 agent,每次调用发给模型的输入由四块组成:系统提示(角色、规则、输出要求),恢复出来的历史消息(checkpointer 还原的全部轮次),本轮新输入(用户消息或上一轮工具结果),以及本轮可用工具的定义。四块的尺寸特征完全不同:系统提示和工具定义基本恒定,一轮几百到几千 token,是固定成本;真正随轮数膨胀的是历史消息这一块,它就是前面账单曲线的主角。预算管理的前提是把五块内容(下一节会补上第五块)各自占多少算清楚,算不清这一步,后面所有优化都在凭感觉。

这个解剖直接给出预算的分配思路。固定成本那一块优化空间在写法(系统提示压缩、工具描述精简、砍掉用不到的工具定义),一次性做完长期受益;变动成本那一块才是三件套的战场。很多团队的第一个优化动作是给系统提示瘦身和砍工具,效果立竿见影,因为这两块每一轮都在重复计费,和历史一个待遇。

严格说还有第五块:按需注入的内容,包括从 Store 检索出来的用户偏好、RAG 检索回来的业务文档片段。它们不随轮数增长,但单次体积可能很大(一段检索回来的文档几千 token 很常见),预算里要给它们留固定配额。配额的意义是防止「检索越多越好」的冲动悄悄吃掉历史的空间:预算 8000,固定块占了 3000,历史就只有 5000,检索片段多了历史就得让路。把这层取舍写进配置,而不是让检索模块和记忆模块互相打架。

还有一块容易被忽略:工具结果。一轮里模型调了搜索,返回 8000 token 的网页内容,这 8000 token 从当轮起就永久进入了历史,之后每一轮都在重发。对工具结果做「进历史前先提炼」(存摘要与链接,不存全文),往往是单笔收益最大的一个动作,它同时减小了当轮输入和此后所有轮次的历史基数。

管住 messages 的三个工具

裁剪trim_messages 按 token 预算从后往前保留。它是三件套里最轻的一个:一次纯函数调用,不动存储、不调模型,任何策略都从它起步:

from langchain_core.messages import trim_messages

trimmed = trim_messages(
    state["messages"],
    max_tokens=3000,
    token_counter=len,  # 按条数近似,生产建议换真实 tokenizer
    strategy="last",
    start_on="human",   # 裁剪后的开头必须是 human 消息
)

三个参数都值得多说一句。token_counter=len 是按「条数」近似不是按真实 token 数,一条带长工具结果的消息可能顶二十条短消息,用它做预算,实际 token 量会失控,生产一定要换成真实 tokenizer。strategy="last" 表示从最新往旧保留,这是对话场景的正确方向,用户永远更在意最近几轮。start_on="human" 保证裁剪后的序列以用户消息开头,不然开头悬着一条孤零零的 assistant 消息,模型会把残缺的对话当成完整历史来理解。

删除:消息带 id,可以显式移除。LangGraph 的惯用组合是「全量移除再回填保留集」:

from langchain_core.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES

def compact(state: MessagesState) -> dict:
    kept = trim_messages(state["messages"], max_tokens=3000,
                         token_counter=len, strategy="last")
    return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *kept]}

RemoveMessage 走的是 add_messages reducer 的删除语义,所以这个节点返回的是合法增量。这个组合乍看暴力(删光再放回),其实是最干净的写法:不用逐条算「哪些 id 要删、哪些要留」,一步把通道收敛到目标集合。注意它改的是 thread 里的实际状态,改动会进 checkpoint,所以它是持久生效的「压缩」,不是每次调用前的临时视图,这正是它和裁剪的本质区别:trim_messages 的结果只喂给当次调用,compact 节点的结果会落盘。

摘要:把旧历史压缩成一条摘要消息,保留关键结论。裁剪是丢弃,摘要是折叠,信息保留度更高,代价是多一次模型调用。自己写逻辑(摘要节点 + 上面两个原语)可行:把超出预算的旧段交给模型折叠成一条消息,替换掉原文。用 LangChain 1.0 的 create_agent 时,现成的 SummarizationMiddleware 在接近阈值时自动摘要,一行接入,省掉自己管阈值的胶水代码。两条提醒:摘要本身有成本和延迟,别把阈值设得太低导致频繁触发;摘要结果进了历史,会影响后续所有轮次,摘要提示词要进版本管理、走评测。

三个工具不是三选一,它们在不同位置各司其职,协作关系值得画清楚。裁剪是每次调用的「读入口径」,发生在把 messages 喂给模型之前,结果不落盘,是最外层的安全网。删除(compact 节点)是流程内的「定期维护」,作为图里的一个节点运行,压缩结果落盘、进 checkpoint,适合在明确的阶段边界做(一个子任务完成后清一次)。摘要是「折叠替换」,同样落盘,和删除的区别是用一条摘要换掉了一整段历史而不是单纯丢弃。按信息价值排序选工具:还想留大意就用摘要,只要最近几轮就裁剪,确定不要了才删除。三个全上也不冲突:入口裁剪兜预算,阶段边界压缩落盘,create_agent 里挂摘要中间件兜底。

Store 的最小用法

from langgraph.store.memory import InMemoryStore

store = InMemoryStore()
store.put(("users", "u42"), "preferences", {"theme": "dark", "tone": "简洁"})
app = g.compile(checkpointer=checkpointer, store=store)

namespace 是元组,层级任意,("users", "u42") 下可以放多组 key。节点里通过注入的 store 参数读写和检索。生产用 Postgres 实现。要注意的边界:Store 不是向量库的替代品,它适合放「小而关键的结论」,整本文档还是该走 RAG。

namespace 的设计有两个实用建议。第一层级放主体(用户、团队、租户),第二层级放维度(偏好、历史结论、待办),这样「这个用户的所有记忆」和「这类记忆」都能直接圈出来。第二,写入时机优先选「得到结论的时刻」而不是「定时批量」:用户明确说了偏好、流程跑出了结论,当场写进 Store,比事后从历史里挖可靠得多。检索侧配上语义索引后可以按意思搜(「这个用户以前抱怨过什么」),索引怎么配见官方文档,这里不展开参数。namespace 定下来之后再改成本很高(数据都在旧路径下),当数据库 schema 认真设计,一步到位。

Store 和 checkpointer 的分工还有一个验证方法:问一句「这个东西换一个新流程还在吗」。在,归 Store;不在,归 checkpointer。用户偏好换流程还在,Store;上一步工具的返回值换流程没意义,checkpointer。

用 Store 还有两条工程习惯。第一,读写比通常是「读多写少」,写的时机在前面说过(得到结论的当场写),读的时机要注意别把 Store 内容整块灌进每轮输入:按需检索(本轮涉及偏好时才查偏好),检索结果作为一条消息注入,这和 RAG 的按需取回是一个道理。第二,写是覆盖语义,同一个 namespace 加 key 再 put 就是更新,用户改了偏好不会堆积旧版本;需要追溯结论来源,就在 value 里带上出处(哪一轮对话、哪个流程),出投诉时能对得上现场。Store 的数据同样会落盘持久化,「什么不该进」一节的三条红线对它同样生效。

什么不该进记忆

三类东西写进去就是负债,每一类的理由都值得记住,因为它们分别对应三种不同的坑。

一次性上下文。 当前页面的临时参数、本次请求的会话标识、前端传来的筛选条件。它们的生命周期是一次请求,放进 messages 或 state,就会跟着快照落盘、跟着历史重发,用完还甩不掉,每一轮都替它们付一次重复费用。该放请求里,用完即弃。

敏感凭证。 API key、访问令牌、他人隐私字段。第 03 篇说过,state 和 Store 都会被持久化、被回放、被备份,凭证进去就等于落盘,之后每一次导出它都在场。凭证走配置和密钥管理,运行时注入,永远不经过 state。这一条在第 16 篇会升级成完整的权限设计。

原始日志。 工具调用的完整返回、大批量查询的结果、调试期打印的中间输出。它们的数据量级和记忆完全不匹配,放进去瞬间撑爆上下文预算和 checkpoint 表。原始数据该在数据仓库里,agent 记忆里放的应该是从里面提炼的结论,顶多带个引用。第 02 篇说的「状态存原始数据」指的是业务事实的小单元,不是未加工的日志流,两个「原始」含义不同,别混。

三类之外给一个三问自检,任何数据进记忆前过一遍:下一轮还需要它吗(不需要,别进 messages);三个月后还需要它吗(需要,进 Store;不需要,用完即弃);别人能看到它可以吗(不可以,根本别让它在 state 和 Store 出现)。三问问完还拿不准,默认不放,真需要时再补,反向的成本低得多。放进去容易,想清出去就难了。

一个演算:策略上线前后的账单

把上面的策略套在一个假想的客服 agent 上演算一遍(数字是演算,不是实测,用它理解量级)。基线沿用开头的设定:平均一轮新增 2000 token,会话平均 30 轮,30 轮累计输入约 93 万 token。

第一轮优化,砍固定成本:系统提示从冗长版瘦到精炼版,工具从十五个砍到八个用得到的。假设每轮固定开销省下 1000 token,30 轮直接省 3 万。第二轮,工具结果进历史前提炼:搜索类结果从平均 8000 token 折到 800,历史基数大幅下降,第 10 轮的单轮输入从 2 万降到 6000 上下。第三轮,预算加摘要:设 8000 预算,超出部分折叠,单轮输入被钉死在预算附近。三轮下来,30 轮会话的累计输入从 93 万量级压到 15 万上下,费用按比例下降,首 token 延迟从「越聊越慢」变成全程平稳。

演算里刻意保留了两个真实世界的粗糙处。一是摘要调用本身的成本:每折一次多一次 LLM 调用,触发频率控制得当时它是小头,控制不当它就是新的增长点,这是监控摘要触发频率的原因。二是收益不是均匀分布的:短会话(三五轮)根本碰不到预算,优化前后没区别;长会话(五六十轮)收益是数倍的。所以给产品报预期时,按会话长度分层算,别报一个平均数。

一条可执行的成本策略

把前面的工具拼成一条从简到繁的策略线,按顺序启用,够用就停:

  1. 先测量。上线前跑一轮真实多轮对话,记录每轮输入 token 和首 token 延迟,这是所有优化的基线。
  2. 设预算。按模型的上下文窗口和成本目标定一个「模型每次最多看多少」的预算值,写进配置,不散落在代码里。
  3. 裁剪兜底。用 trim_messages 保证任何情况下不超过预算,这是安全网,先兜住再谈优化。
  4. 摘要折叠。预算内装不下必要历史时,引入摘要:超过预算的旧段折叠成摘要消息,保留结论丢掉过程。
  5. 结论进 Store。流程里产生的长期有效结论(用户偏好、已确认的事实)当场写 Store,这样 messages 里丢掉的细节在长期记忆里有备份,裁剪的底气就足了。
  6. 改策略必跑评测。压缩策略直接改变模型看到的输入,进而改变输出质量,任何调整都要用同一批历史输入跑前后对比,第 15 篇的评测流程就是干这个的。

策略上线之后还要有观测,三个指标构成最小监控集。每轮平均输入 token:它是成本曲线的直接读数,策略生效它就该被压平。首 token 延迟分位数:它回答用户感知的「变慢」,和 token 数对照看,能发现「token 没超但延迟高了」的异常。摘要触发频率:它暴露阈值设置是否合理,触发太频繁说明预算太紧或单轮增量太大,两者的解法不同。三个指标都有异常再看策略,别凭感觉调。

顺序的意义在于每一步都可独立回退:先有测量,才知道策略有没有效;先有安全网,才敢上摘要;先有 Store 兜底,才敢放心裁剪。跳步直接上摘要,出了「agent 忘了用户说过的话」的投诉,你分不清是阈值设错还是摘要丢了关键信息。

落地分工也有个惯用拆法。平台侧提供统一的能力:预算配置、裁剪与摘要的封装、token 监控面板,做成业务团队拿来就用的中间件。业务侧只做两个决定:预算设多少、哪些结论值得进 Store。这两个决定依赖业务判断(数字核对类会话预算就得高,闲聊类可以紧),不该由平台拍。职责分开,策略调整才不会每次都变成跨团队项目。

两个常见争议

「全量给模型不是更准吗,裁剪丢的信息怎么办?」 这个质疑值得严肃对待,回答它靠实验不靠立场。做法很简单:取一批真实历史会话,同一组问题分别用全量上下文和压缩后上下文各跑一遍,对比正确率和质量分。多数客服与助理类任务的实测倾向是压缩后持平甚至更好(干扰信息少了),但你的任务可能不同,比如长链路的数字核对任务就真的依赖全量。本篇的立场不是「压缩总是对」,而是「压缩该由数据决定」,实验半天就能给你答案。

「压缩是不是过度设计,换个便宜模型不行吗?」 换便宜模型压的是单价,压缩压的是量级,两者作用在乘法的不同因子上。单价 2 折叠加 6 倍的量,还是 1.2 倍;量级问题不解决,单价折扣只是延缓。反过来,量级先压下来,再叠加模型降价和缓存折扣,曲线才是真往下走。预算策略是结构性收益,换模型是参数性收益,先结构后参数。

权衡

压缩有信息损失。摘要丢细节,裁剪丢旧轮次,有些任务就是需要第 3 轮的精确数字,比如对账、报价、合同条款核对。这类任务要么别压缩相关轮次,要么把关键数字主动写入 Store 再放心裁剪 messages,让长期记忆兜底。判断一个任务属不属于这一类的标准:回答的正确性是否依赖历史里的具体数值而非大意。是,就不能只靠摘要,数字要专门安置。

成本和质量要一起看。压缩策略变了,账单变便宜只是第一步验证,输出质量有没有变差必须靠评测回答。实践中经常出现「成本降了三成,投诉涨了一倍」的反面案例,问题不在压缩本身,在压缩完没看质量。让每一分省下的钱都过一遍评测闸门。

任务形态不同,这篇的适用方式也不同。聊天型任务(多轮 invoke,轮间靠历史衔接)是本篇的主场,账单曲线和三件套都按轮数展开。长流程型任务(一次 invoke 内几十个步骤,第 20 篇的流水线那种)同样的机制换个粒度发生:历史随步数增长,每一步的工具结果都在加厚 state 和 messages,预算和裁剪一样要设,只是监控指标从「每轮 token」换成「每步 token」。两类任务共同的底层事实不变:存档免费,重发才是成本。

最后回到三套东西的边界上收束:这篇所有工具都只在「上下文工程」这一层操作,checkpointer 和 Store 的数据一个字没少。也就是说这里没有任何动作是不可逆的,最坏情况是把策略改回去,数据都还在。这是这套架构给的最大安全感:存储与呈现分离之后,成本问题和记忆问题第一次变成可以分开优化的两件事。

下一篇换个视角:StateGraph 的图写法和这篇的函数式写法(@entrypoint@task)都能落地同样的持久化语义,什么时候该不画图、直接写函数,是第 05 篇的选型题。

延伸阅读


原文出处:91ai / LangGraph 生产实战系列

评论

0
登录后可以参与评论和讨论。

还没有评论

欢迎留下第一条评论,帮助这篇内容更快形成讨论。