Agent + RAG 如何处理多轮对话中的上下文识别与记忆?原理、架构与实战

魏远标 Lv7

提问

Agent + RAG 在多轮对话中,是怎么处理上下文语义识别和记忆的?为什么「它」「那个」「接着呢」它都能懂?对话长了怎么办?

参考答案

一句话:把”整段对话历史”按”短期记忆 + 长期记忆 + 工作记忆”三层管理,再在每次调用 LLM 前做”上下文压缩 + Query 改写”,让模型始终只看到最有用的信息。


一、为什么多轮对话是难题

LLM 本身没有记忆——每次调用都是 stateless(无状态)。

1
2
3
4
5
用户:「Java 怎么读文件?」
Agent:讲一堆

用户:「它能读多大?」← "它" 指什么?
Agent:? ← LLM 不知道"它"是 Java、文件读取、还是别的

LLM 必须看到完整对话历史才能理解指代。所以每个 Agent 框架都要做一件事:管理对话历史,按策略喂给模型


二、三层记忆模型(行业标准)

范围 实现方式 典型大小
短期记忆 当前会话 ChatHistory 数组 最近 N 轮(10-50)
工作记忆 当前任务 临时变量 / scratchpad KB 级
长期记忆 跨会话 向量库 + KV 存储 MB-GB 级

LangChain / Spring AI / LangGraph 都遵循这个模型。


三、LangChain 的实现(最经典)

3.1 5 种 ConversationBufferMemory 模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from langchain.memory import (
ConversationBufferMemory,
ConversationBufferWindowMemory,
ConversationSummaryMemory,
ConversationSummaryBufferMemory,
ConversationTokenBufferMemory,
)

# 1. 完整缓冲(最简单)
memory = ConversationBufferMemory(
memory_key="chat_history",
return_messages=True
)

# 2. 滑动窗口(只保留最近 k 轮)
memory = ConversationBufferWindowMemory(
k=10, # 只记最近 10 轮
memory_key="chat_history"
)

# 3. 摘要压缩(用 LLM 把历史压缩成摘要)
memory = ConversationSummaryMemory(
llm=llm,
memory_key="chat_history"
)

# 4. 混合:最近 N 轮完整 + 更早的压缩成摘要(**最常用**)
memory = ConversationSummaryBufferMemory(
llm=llm,
max_token_limit=2000 # 最近的消息超 2000 token 就开始压缩
)

# 5. 按 token 数控制
memory = ConversationTokenBufferMemory(
llm=llm,
max_token_limit=2000
)

3.2 推荐:Summary + Buffer 混合

1
2
3
4
5
6
7
8
9
# 最近 10 轮完整保留(保证指代消解)
# 更早的用 LLM 压缩成 200 字摘要(控制 token)
# 完美平衡:质量 + 成本
memory = ConversationSummaryBufferMemory(
llm=ChatOpenAI(model="gpt-4o-mini"),
max_token_limit=4000,
memory_key="chat_history",
return_messages=True
)

四、Query 改写:解决”它”、”那个”指代问题

光保留历史不够,还得让模型理解指代。最常用是 Query Rewriting

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def rewrite_query(question, chat_history):
"""把含糊的问题改写成独立问题"""

last_turn = chat_history[-2:] # 取最近 1 轮(user + assistant)

prompt = f"""基于以下对话历史,把用户的新问题改写成独立的、完整的问题。

对话历史:
{format(last_turn)}

用户新问题:{question}

改写后的问题(不要回答问题本身,只改写):"""

return llm.invoke(prompt).content

# 例:
# 历史:「Java 怎么读文件?」
# 当前:「它能读多大?」
# 改写后:「Java 用 FileInputStream 读文件的最大尺寸限制是多少?」

变体:HyDE(让 LLM 先生成假设答案,再去检索假设答案的向量)。


五、LangGraph 的状态机做法(生产级)

LangGraph 把记忆当成「状态」来管理,支持并行 + 回退 + 人介入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages

class State(TypedDict):
# messages 自动累积(add_messages 是 reducer)
messages: Annotated[list, add_messages]
# 工作记忆
current_task: str
# 长期记忆摘要
summary: str

def chatbot(state: State):
# 1. 上下文:messages + summary
context = f"对话摘要:{state.get('summary', '')}\n\n对话历史:{state['messages']}"

# 2. LLM 生成
response = llm.invoke(context)

# 3. 定期摘要(超过 N 轮触发)
if len(state['messages']) > 10:
summary = llm.invoke(f"请摘要以下对话:{state['messages']}")
return {"messages": [response], "summary": summary}
return {"messages": [response]}

# 状态机
graph = StateGraph(State)
graph.add_node("chat", chatbot)
graph.add_edge("chat", END)
graph.set_entry_point("chat")

app = graph.compile()

# 多轮对话(自动累积 messages)
app.invoke({"messages": [("user", "Java 怎么读文件?")]})
app.invoke({"messages": [("user", "它能读多大?")]}) # 自动累积上文

优势

  • 自动累积对话(add_messages reducer)
  • 摘要机制随时挂上
  • 可以持久化(checkpointing)
  • 人在回路(human-in-the-loop)天然支持

六、OpenClaw 的 Hook 机制(飞牛面试题提到过)

OpenClaw 把「记忆」做成可插拔的中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
# 在用户问题进来前自动改写
@hook("before_agent_run")
async def rewrite_question(context):
user_msg = context.current_message
history = context.chat_history

# 用 LLM 把含糊问题改写
if has_pronoun(user_msg): # 包含"它/那个/这个"
rewritten = await rewrite(user_msg, history)
context.current_message = rewritten

# 把摘要 + 近期历史压进 context
context.memory_summary = await summarize_old_messages(history)

实战价值:把”上下文处理”从业务代码里剥离出来,配置化。


七、Spring AI 实现(Java 生态)

Spring AI 1.0+ 的 ChatClient 提供了 Advisor 机制做记忆管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Service
public class RagService {

private final ChatClient chatClient;

public RagService(ChatClient.Builder builder,
VectorStore vectorStore,
ChatMemory chatMemory) {
this.chatClient = builder
.defaultAdvisors(
// 1. 检索增强(RAG)
new QuestionAnswerAdvisor(vectorStore),
// 2. 短期记忆(ChatMemory)
new MessageChatMemoryAdvisor(chatMemory)
)
.build();
}

public String chat(String sessionId, String userMessage) {
// conversationId 用于区分不同会话
return chatClient.prompt()
.advisors(a -> a
.param("conversationId", sessionId)
.param("topK", 5))
.user(userMessage)
.call()
.content();
}

@Bean
public ChatMemory chatMemory(JdbcTemplate jdbcTemplate) {
// 用 JDBC 存到 MySQL(生产用数据库持久化)
return MessageWindowChatMemory.builder()
.chatMemoryRepository(new JdbcChatMemoryRepository(jdbcTemplate))
.maxMessages(20) // 只保留最近 20 条
.build();
}
}

特点

  • ChatMemory 抽象(InMemory / JDBC / Redis
  • 自动持久化到数据库(重启不丢)
  • MessageWindowChatMemory 滑动窗口实现

八、Claude Agent SDK 的做法(最新)

1
2
3
4
5
6
7
8
9
10
11
12
# SKILL.md(Markdown 即工具)
# When user asks a follow-up question with pronouns like "它/那个/接着",
# automatically rewrite using last 3 turns of context.

def handle_pronoun(context):
last_messages = context.chat_history[-3:]
if needs_rewrite(context.current_message):
rewritten = await llm.invoke(
f"重写问题使其独立:{context.current_message}\n基于:{last_messages}"
)
return rewritten
return context.current_message

把”上下文识别”做成 Skill,业务代码不感知。


九、常见坑 & 实战建议

坑 1:上下文太长导致成本爆炸

症状:10 轮对话后 token 涨到 8K,每次调用 $0.05

修法

  • max_token_limit=4000 自动截断
  • ConversationSummaryMemory 摘要老历史
  • 用 Gemini Flash 这类便宜模型做摘要

坑 2:上下文窗口溢出 → LLM 报错

症状:对话超过 32K token,API 返回 400

修法

  • 滑动窗口(最近 N 轮)
  • RAG 检索时只保留 top-5 文档块
  • 强制压缩老历史

坑 3:指代消解失败

症状:「它能读多大?」→ LLM 答非所问

修法

  • Query Rewriting(在 LLM 之前用另一个 LLM 改写)
  • 把最近 3 轮对话和当前问题拼一起传给主 LLM

坑 4:跨会话记忆泄露

症状:A 用户问的问题,B 用户能看到

修法

  • session_id 严格隔离
  • 长期记忆存到 users/{user_id}/memory/
  • 检索时加 user_id 过滤

坑 5:记忆系统变成性能瓶颈

症状:每次对话都查 Redis 拿历史,慢

修法

  • MessageWindowChatMemory 内存缓存最近 N 轮
  • 超出窗口的用 RAG 从向量库检索相关历史片段
  • 不存全部,只存「摘要」和「关键事实」

十、生产级架构建议

短期对话(单次会话)

1
2
3
4
5
6
7
8
每轮:
1. 读 chat_history(最近 10 轮)
2. Query 改写(处理"它/那个")
3. RAG 检索(top-5)
4. 组装 Prompt = System + 摘要 + 历史 + 检索结果 + 当前问题
5. 调 LLM
6. 写回 chat_history
7. 检查是否需要触发"摘要压缩"(超过 N 轮)

长期记忆(跨会话)

1
2
3
4
5
6
用 LLM 提取对话中的「关键事实」,存向量库 + KV:
- 用户偏好:"用户是 Java 后端工程师"
- 用户历史问题:"之前问过 Redis 集群"
- 用户身份:"叫 Sherwin.Wei"

下次对话开始时,检索相关长期记忆,加入 system prompt

Spring AI 推荐配置

1
2
3
// 生产:JDBC 存对话历史(重启不丢)
// 检索:Qdrant / Milvus 向量库
// 摘要:每 20 轮触发一次,自动压缩老历史

十一、总结:多轮对话的三层心智

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌──────────────────────────────────┐
│ 长期记忆(跨会话) │ ← 向量库 + KV
│ "用户是 Java 工程师" │
└──────────────┬───────────────────┘
│ 检索时注入

┌──────────────────────────────────┐
│ 短期记忆(当前会话) │ ← ChatHistory
│ 最近 10 轮对话 │
└──────────────┬───────────────────┘
│ 摘要压缩

┌──────────────────────────────────┐
│ 工作记忆(当前问题) │ ← scratchpad
│ Query 改写 + 检索结果 │
└──────────────────────────────────┘

LLM 生成回答

核心心法

让 LLM 永远只看最相关的信息——不管历史多长、记忆多少。


相关阅读


最后更新:2026-09-04