18|上下文与记忆:Agent 应该记住哪些信息?
《Eino 实战:用 Go 构建 AI 应用与智能体》· 第 18 篇
关键词:对话历史、SessionValues、上下文窗口、摘要压缩、长期记忆
前置条件:已完成 17|Runner 与事件流。
第 17 篇末尾提到,Agent 不会自动记住上一次运行。这一篇正面回答:“记住”到底有几种含义,各自由谁负责。
把这件事讲清楚,能避免两类常见错误:以为框架会自动记忆,以及把所有东西都塞进对话历史。
一、先区分四种“记忆”
“记忆”这个词被用得太宽泛了。在 Agent 系统里,至少对应四种不同机制:
| 类型 | 生存范围 | 由谁管理 | 典型内容 |
|---|---|---|---|
| 对话历史 | 一次请求内传递 | 应用层维护 | 用户与助手的往返消息 |
| 运行内共享状态 | 一次运行内 | 框架(SessionValues) | 租户 ID、请求追踪号 |
| 单次执行内的状态 | 一次执行内 | 框架(State) | 循环计数、中间结果 |
| 长期记忆 | 跨请求、跨天 | 应用 + 存储 | 用户偏好、历史结论 |
框架只负责中间两种。对话历史和长期记忆都需要应用层自己处理。
二、对话历史:必须手动维护
下面这段程序用两次独立运行和一次累积历史做对比(已实机运行):
=== 两次独立运行(无历史)=== 模型看到/回复: 收到 2 条消息,最后一条: "第 1 个问题" 模型看到/回复: 收到 2 条消息,最后一条: "第 2 个问题" === 传入累积历史 === 模型看到/回复: 收到 3 条消息,最后一条: "第 1 个问题" 模型看到/回复: 收到 5 条消息,最后一条: "第 2 个问题"数字变化说明了机制:
- 独立运行:每次都是 2 条消息(系统消息 + 用户消息),互不影响。
- 累积历史:第二次运行是 5 条(系统 + 用户1 + 助手1 + 用户2 + 助手2),模型能看到完整上下文。
维护历史的代码很直白:
history:=[]*schema.Message{schema.SystemMessage("你是工单助手。")}for_,question:=rangequestions{history=append(history,schema.UserMessage(question))iter:=runner.Run(ctx,history)// ... 消费事件,拿到回复history=append(history,schema.AssistantMessage(reply,nil))}两个必须遵守的规则(和第 03 篇一致):
- 助手回复要以
assistant角色回到历史。 - 每个会话的历史必须独立,绝不能跨用户共享同一个切片。
三、上下文窗口:历史不能无限增长
历史越长,上下文越大。后果有三层:
| 影响 | 表现 |
|---|---|
| 成本 | 每轮都重发全部历史,Token 消耗随轮次上升 |
| 延迟 | 输入变长,首字节时间增加 |
| 效果 | 关键信息被大量历史稀释,模型反而更抓不住重点 |
“历史全带上”不是稳妥策略,而是资源浪费。通常需要主动管理。
三种常用策略
1. 滑动窗口:只保留最近 N 轮
constmaxTurns=10iflen(history)>maxTurns*2+1{// +1 是系统消息history=append(history[:1],history[len(history)-maxTurns*2:]...)}简单可靠,但会丢失早期重要信息(比如用户最初说明的问题背景)。
2. 摘要压缩:把早期历史总结成一段话
把较早的消息送给模型总结,用一段摘要替代原文:
[早期对话摘要] 用户报告打印机离线,已确认网线与网段配置正常,尝试重启无效。 [最近对话] 用户:那接下来怎么办? 助手:……压缩能保留要点、显著减小体积,代价是细节丢失,且摘要本身也要调用模型(额外成本)。
3. 按需检索:历史也当作可检索的资料
把历史存起来,需要时按相关性召回,而不是全量带上。适合长期会话,实现复杂度最高。
怎么选
| 场景 | 建议 |
|---|---|
| 短会话(几轮内解决) | 全量带上,不必优化 |
| 中等长度、偶尔超长 | 滑动窗口 |
| 长会话、需要保留背景 | 摘要压缩 |
| 跨天或长期陪伴式 | 检索 + 摘要组合 |
先测量再优化。记录实际历史的 Token 用量,看它是否真的成为瓶颈。
四、SessionValues:一次运行内的共享键值
有些数据不属于“对话”,而属于“这次运行的上下文”。例如当前用户、租户、追踪号。
这类数据用 SessionValues 传递:
iter:=runner.Run(ctx,messages,adk.WithSessionValues(map[string]any{"user_id":"u-1001","tenant_id":"t-01",}))在工具或中间件内部读取:
ifv,ok:=adk.GetSessionValue(ctx,"user_id");ok{// 使用 v}它的特点:
| 特点 | 说明 |
|---|---|
基于context | 随调用链传递,天然线程安全地共享 |
| 一次运行内有效 | 不跨请求 |
| 并发安全 | 可被多个 Agent 并发读写 |
有一个容易踩的坑:Runner 运行时会重新初始化 Context。所以想在运行前注入数据,必须用WithSessionValues这个 Option,而不是自己往ctx里塞值:
// 正确:通过 Option 注入runner.Run(ctx,messages,adk.WithSessionValues(map[string]any{"user_id":"u-1001"}))// 错误:直接 context.WithValue 会被运行时的初始化覆盖运行过程中若需要新增值,用adk.AddSessionValue(ctx, key, value)。
不要把大块内容放进 SessionValues。它是用来传递小型上下文标识的,不是数据仓库。会话变量还可能被序列化进检查点,过大的值会影响性能。
五、执行状态与检查点:容易误解的地方
这里有两个机制,用途不同,容易混:
| 机制 | 作用 | 生存期 |
|---|---|---|
执行状态(ProcessState) | 一次执行内跨节点共享数据 | 单次执行 |
| 检查点(Checkpoint) | 中断后恢复运行现场 | 直到恢复或过期 |
第 07 篇讲过执行状态:每次执行都是全新的,不跨请求保留。
检查点则用于人工审批等场景(第 20 篇):运行到一半被中断,把现场存下来,稍后接着跑。
两者都不是长期记忆:
- 执行状态解决“同一次执行内部怎么共享数据”。
- 检查点解决“中断了怎么接着跑”。
- 长期记忆需要你自己设计存储结构并持久化。
六、长期记忆:属于应用层的问题
Agent 要“记住用户上次说过什么”,需要应用层设计。这不是框架能替你决定的,因为它涉及业务语义、隐私和合规。
需要想清楚的问题:
| 问题 | 为什么重要 |
|---|---|
| 记什么? | 用户偏好?历史结论?全部对话? |
| 存多久? | 过期策略与合规要求(如个人信息保存期限) |
| 谁可见? | 跨用户隔离、跨租户隔离 |
| 怎么用? | 全量注入历史,还是检索相关片段 |
| 如何删除? | 用户要求删除时能不能真的删干净 |
一种常见做法是分两层:
会话历史(短期) └─ 存最近若干轮,随请求带上 用户画像(长期) └─ 存稳定的事实,如偏好、常用设备、所属部门 └─ 以结构化字段或可检索片段形式存在 └─ 按需注入,而不是每轮全带关键原则:长期记忆要“按需注入”,而不是“全量携带”。把用户一年的历史都塞进上下文,既贵又无效。
隐私红线
- 用户数据要有明确的保留期限和删除机制。
- 敏感信息不应进入模型上下文,除非业务确有必要。
- 记住的内容要在界面上可见、可管理——用户有权知道系统记住了什么。
七、完整示例
下面这段程序演示历史维护与 SessionValues 注入,不访问网络:
packagemainimport("context""fmt""log""github.com/cloudwego/eino/adk""github.com/cloudwego/eino/components/model""github.com/cloudwego/eino/schema")// passthroughModel 直接回显最后一条用户消息,便于观察上下文。typepassthroughModelstruct{}func(m*passthroughModel)Generate(_context.Context,in[]*schema.Message,_...model.Option)(*schema.Message,error){returnschema.AssistantMessage(fmt.Sprintf("收到 %d 条消息,最后一条: %q",len(in),in[len(in)-1].Content),nil),nil}func(m*passthroughModel)Stream(ctx context.Context,in[]*schema.Message,opts...model.Option)(*schema.StreamReader[*schema.Message],error){msg,err:=m.Generate(ctx,in,opts...)iferr!=nil{returnnil,err}sr,sw:=schema.Pipe[*schema.Message](1)gofunc(){defersw.Close();sw.Send(msg,nil)}()returnsr,nil}funcmain(){iferr:=run();err!=nil{log.Fatal(err)}}funcrun()error{ctx:=context.Background()agent,err:=adk.NewChatModelAgent(ctx,&adk.ChatModelAgentConfig{Name:"assistant",Description:"助手",Instruction:"你是工单助手。",Model:&passthroughModel{},})iferr!=nil{returnerr}runner:=adk.NewRunner(ctx,adk.RunnerConfig{Agent:agent})// 1. 每次 Run 都是独立执行:历史不会自动累积。fmt.Println("=== 两次独立运行(无历史)===")fori:=1;i<=2;i++{iter:=runner.Run(ctx,[]*schema.Message{schema.UserMessage(fmt.Sprintf("第 %d 个问题",i))})printOutput(iter)}// 2. 手动维护历史并传入,模型才能看到完整上下文。fmt.Println("\n=== 传入累积历史 ===")history:=[]*schema.Message{schema.SystemMessage("你是工单助手。")}fori:=1;i<=2;i++{history=append(history,schema.UserMessage(fmt.Sprintf("第 %d 个问题",i)))iter:=runner.Run(ctx,history)reply:=printOutput(iter)// 把回复追加回历史,下一轮才能看到。ifreply!=""{history=append(history,schema.AssistantMessage(reply,nil))}}// 3. SessionValues:一次运行内跨节点共享的键值,可通过 Option 注入。fmt.Println("\n=== 通过 WithSessionValues 注入运行期数据 ===")iter2:=runner.Run(ctx,[]*schema.Message{schema.UserMessage("读取会话变量")},adk.WithSessionValues(map[string]any{"user_id":"u-1001"}))// 在工具或节点内部可用 adk.GetSessionValue(ctx, "user_id") 读取。_=iter2 fmt.Println("已注入 user_id 到本次运行的 SessionValues(供工具/中间件读取)")returnnil}funcprintOutput(iter*adk.AsyncIterator[*adk.AgentEvent])string{varoutstringfor{event,ok:=iter.Next()if!ok{break}ifevent.Err!=nil{fmt.Println(" 错误:",event.Err)continue}ifevent.Output==nil||event.Output.MessageOutput==nil{continue}mv:=event.Output.MessageOutputifmv.Message!=nil{out=mv.Message.Content fmt.Println(" 模型看到/回复:",mv.Message.Content)}}returnout}八、常见问题
Agent 记不住上一轮
历史没有传进去。每次Run都是独立执行,需自行维护并传入历史。
多用户之间串了上下文
历史切片被共享了。每个会话必须有独立的历史结构。
请求越来越慢、越来越贵
历史未做截断,Token 随轮次增长。加入窗口或摘要策略。
注入的 SessionValue 读不到
用了context.WithValue而非WithSessionValues。Runner 会重新初始化 Context,直接塞进去的值会丢失。
检查点恢复后状态不对
检查点恢复的是运行现场,不等于业务数据的一致性。涉及写操作时仍要保证幂等(第 29 篇)。
用户要求“忘记我说过的话”
需要应用层实现删除。设计存储时就要考虑可删除性,不要只往一个不断增长的日志里追加。
九、小结
- “记忆”分四种:对话历史、运行内共享值、执行状态、长期记忆,框架只负责中间两种。
- 对话历史必须手动维护,回复要以
assistant角色回填。 - 历史不能无限增长,可用滑动窗口、摘要压缩或按需检索控制。
- SessionValues 用于传递小型运行期标识;必须用
WithSessionValues注入。 - 检查点是“中断恢复现场”,不是长期记忆,也不保证业务幂等。
- 长期记忆属于应用层设计,遵循按需注入、明确保留期限、可删除的原则。
下一篇《Middleware 实战:在决策循环中加入控制能力》,我们把审计、校验、上下文处理等横切能力挂到 Agent 的生命周期上。
参考资料与版本说明
资料核对日期为2026 年 10 月 9 日:
- Eino ADK 概述
- Agent 协作(SessionValues 部分)
- Eino 主仓库
本文示例按Eino v0.9.21编写,使用模拟模型(不访问网络),已实机运行,正文输出为真实结果。