Agent 开发入门(三):给模型装上记忆 — 对话上下文管理
Agent 开发入门(三):给模型装上记忆 — 对话上下文管理
模型没有记忆,每次调用都是全新的。上下文管理就是我们替它"记住"的方式。
前两篇我们搞定了 API 基础调用和 Function Calling,现在模型能帮我们查天气、调工具了。但你有没有注意到一个问题:每次调用都是独立的,模型根本不知道你上一句说了什么。
这篇就来解决这个问题。
模型没有记忆,这不是 bug
很多人第一次用 ChatGPT 的时候会觉得它"记住"了上下文,能接着聊。但实际上,模型本身没有任何记忆。它是无状态的,每次调用都是全新的推理。
那为什么聊天产品能"记住"上文?因为产品帮你做了一件事:把历史消息全部塞进了 messages 数组,一起发给模型。
模型看到的不是"你刚才说了什么",而是"这一次请求里,你把之前所有对话都带过来了"。
第一轮:
messages = [
{ role: "user", content: "我叫张三" }
]
第二轮:
messages = [
{ role: "user", content: "我叫张三" },
{ role: "assistant", content: "你好,张三!" },
{ role: "user", content: "我叫什么名字?" }
]
第二轮里,模型能回答"你叫张三",不是因为它记住了,而是因为第一轮的内容还在 messages 里。
理解这一点很重要,因为它直接决定了我们怎么管理上下文。
最简单的实现:用 List 存消息
既然上下文就是 messages 列表,最直接的做法就是维护一个 List,每次对话追加,每次调用把完整列表发出去。
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
public class SimpleChat {
private static final String API_KEY = System.getenv("DASHSCOPE_API_KEY");
private static final String API_URL =
"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
// 对话历史,贯穿整个会话
private final List<String> messages = new ArrayList<>();
public String chat(String userInput) throws Exception {
// 追加用户消息
messages.add("""
{"role":"user","content":"%s"}""".formatted(escape(userInput)));
// 构建完整请求体
String messagesJson = String.join(",", messages);
String body = """
{
"model": "qwen-plus",
"messages": [%s]
}""".formatted(messagesJson);
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + API_KEY)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
// 解析回复(简单字符串截取,不引入 JSON 库)
String reply = extractContent(response.body());
// 把模型回复也追加进历史
messages.add("""
{"role":"assistant","content":"%s"}""".formatted(escape(reply)));
return reply;
}
private String extractContent(String json) {
int start = json.indexOf("\"content\":\"") + 11;
int end = json.indexOf("\"", start);
return json.substring(start, end);
}
private String escape(String s) {
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n");
}
public static void main(String[] args) throws Exception {
SimpleChat chat = new SimpleChat();
System.out.println(chat.chat("我叫张三,是一名 Java 开发者"));
System.out.println(chat.chat("我叫什么名字?"));
System.out.println(chat.chat("我是做什么的?"));
}
}
运行结果大概是:
你好,张三!很高兴认识你。
你叫张三。
你是一名 Java 开发者。
完美。但这个方案有个致命问题。
问题来了:token 是有上限的
每个模型都有 context window 限制,也就是单次请求能处理的最大 token 数。比如 qwen-plus 是 128K token,听起来很多,但:
- 1 个 token 大约等于 1.5 个中文字,或者 0.75 个英文单词
- 一篇 2000 字的文章大概是 1300+ token
- 如果对话持续几十轮,加上工具调用的结果,很快就会逼近上限
超限之后,API 会直接报错。而且就算没超限,发送的 token 越多,费用越高,响应也越慢。
所以我们需要一个策略:在保留足够上下文的前提下,控制 messages 的总量。
三种策略,各有取舍
策略一:滑动窗口
最简单粗暴:只保留最近 N 轮对话,更早的直接丢掉。
保留最近 3 轮:
[第1轮] 丢弃
[第2轮] 丢弃
[第3轮] 保留 ← 窗口起点
[第4轮] 保留
[第5轮] 保留 ← 当前
优点:实现简单,token 数可控。
缺点:早期信息丢失。如果用户在第 1 轮说了自己的名字,第 10 轮再问,模型就不知道了。
适合场景:任务型对话,每轮相对独立,不需要记住很久之前的信息。
策略二:摘要压缩
当历史消息超过阈值时,让模型把早期对话总结成一段摘要,用摘要替代原始消息。
原始:[第1轮][第2轮][第3轮][第4轮][第5轮]
压缩后:[摘要:前3轮的核心信息][第4轮][第5轮]
优点:信息损失少,token 数也能控制。
缺点:需要额外调用一次模型来生成摘要,有延迟和费用。
适合场景:长对话、需要记住早期关键信息的场景。
策略三:混合策略(推荐)
实际项目里用得最多的是这种组合:
messages = [
system prompt(角色设定、背景信息),
摘要消息(早期对话的压缩版),
最近 N 轮原始对话
]
system prompt 放固定的角色设定,摘要放历史关键信息,最近几轮保持原样。这样既有背景,又有细节,token 也可控。
Java 实战:ChatHistory 类
我们来实现一个支持滑动窗口的 ChatHistory 类,把上下文管理逻辑封装起来。
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
/**
* 对话历史管理,支持滑动窗口策略。
* 每条消息用 role + content 表示,内部用 Deque 维护窗口。
*/
public class ChatHistory {
public record Message(String role, String content) {}
private final String systemPrompt;
private final int maxRounds; // 最多保留几轮(一轮 = user + assistant)
private final Deque<Message> window; // 滑动窗口
public ChatHistory(String systemPrompt, int maxRounds) {
this.systemPrompt = systemPrompt;
this.maxRounds = maxRounds;
this.window = new ArrayDeque<>();
}
/** 追加一条消息 */
public void add(String role, String content) {
window.addLast(new Message(role, content));
// 超出窗口就从头部移除(一次移除一条,调用方保证成对追加)
while (window.size() > maxRounds * 2) {
window.pollFirst();
}
}
/** 构建发送给 API 的 messages JSON 数组 */
public String toMessagesJson() {
List<String> parts = new ArrayList<>();
// system prompt 始终在最前面
if (systemPrompt != null && !systemPrompt.isBlank()) {
parts.add(messageJson("system", systemPrompt));
}
// 滑动窗口内的历史消息
for (Message msg : window) {
parts.add(messageJson(msg.role(), msg.content()));
}
return "[" + String.join(",", parts) + "]";
}
/** 估算当前 token 数(粗略:中文 1.5字/token,英文 0.75词/token) */
public int estimateTokens() {
int chars = systemPrompt == null ? 0 : systemPrompt.length();
for (Message msg : window) {
chars += msg.content().length();
}
return (int) (chars / 1.5);
}
public int getRoundCount() {
return window.size() / 2;
}
private String messageJson(String role, String content) {
return """
{"role":"%s","content":"%s"}""".formatted(role, escape(content));
}
private String escape(String s) {
return s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "");
}
}
然后把它接入我们的对话客户端:
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class ChatClient {
private static final String API_KEY = System.getenv("DASHSCOPE_API_KEY");
private static final String API_URL =
"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
private final ChatHistory history;
private final HttpClient httpClient = HttpClient.newHttpClient();
public ChatClient(String systemPrompt, int maxRounds) {
this.history = new ChatHistory(systemPrompt, maxRounds);
}
public String chat(String userInput) throws Exception {
history.add("user", userInput);
String body = """
{
"model": "qwen-plus",
"messages": %s
}""".formatted(history.toMessagesJson());
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + API_KEY)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response =
httpClient.send(request, HttpResponse.BodyHandlers.ofString());
String reply = extractContent(response.body());
history.add("assistant", reply);
System.out.printf("[轮次: %d, 估算token: ~%d]%n",
history.getRoundCount(), history.estimateTokens());
return reply;
}
private String extractContent(String json) {
int start = json.indexOf("\"content\":\"") + 11;
int end = json.indexOf("\"", start);
return json.substring(start, end);
}
public static void main(String[] args) throws Exception {
// 最多保留 5 轮对话
ChatClient client = new ChatClient(
"你是一个 Java 技术助手,回答简洁直接。",
5
);
String[] questions = {
"我叫张三,是一名 Java 开发者",
"Java 里怎么创建线程池?",
"刚才说的 Java 开发者是谁?",
"线程池的核心参数有哪些?",
"我的名字是什么?"
};
for (String q : questions) {
System.out.println("用户: " + q);
System.out.println("模型: " + client.chat(q));
System.out.println();
}
}
}
运行后你会看到,在 5 轮窗口内,模型能正确回答"你叫张三"。一旦超出窗口,早期信息就丢失了。这是滑动窗口的正常行为,不是 bug。
token 怎么估算
精确计算 token 需要用 tiktoken 这类库,但对于日常开发,粗略估算就够用了:
| 内容类型 | 估算规则 |
|---|---|
| 中文 | 1 token ≈ 1.5 个字 |
| 英文 | 1 token ≈ 0.75 个单词 |
| 代码 | 1 token ≈ 3-4 个字符 |
比如一段 300 字的中文回复,大概是 200 token。一次包含 10 轮对话的请求,每轮平均 200 字,大概是 10 × 200 / 1.5 ≈ 1333 token。
实际使用时,API 响应里会返回精确的 token 用量:
"usage": {
"prompt_tokens": 1250,
"completion_tokens": 180,
"total_tokens": 1430
}
可以把这个数据记录下来,用于监控和成本控制。
和 Agent 的关系
前两篇我们实现了工具调用,但一个真正的 Agent 往往需要多步推理:先查信息,再分析,再决策,再执行。这个过程可能跨越十几轮甚至几十轮对话。
上下文管理直接影响 Agent 的"智商":
- 上下文太短:Agent 忘记了前几步做了什么,开始重复或者走弯路
- 上下文太长:token 超限,或者模型在海量信息里找不到重点(这个现象叫 lost in the middle)
- 上下文管理得当:Agent 能清楚地知道"我做了什么、现在在哪一步、下一步该干什么"
所以上下文管理不只是"让模型记住聊天记录",它是 Agent 推理链路的基础设施。
一个实用的 Agent 上下文结构通常长这样:
system: 角色定义 + 任务目标 + 工具说明
assistant: [摘要] 已完成:步骤1查询了用户信息,步骤2确认了权限
user: [工具结果] 查询结果:...
assistant: 下一步我需要...
user: [当前用户输入]
把任务状态、已完成步骤、关键中间结果都放进上下文,Agent 才能在多步推理中保持连贯。
小结
这篇的核心就一句话:模型没有记忆,上下文管理就是我们替它记住的方式。
具体来说:
- 最简单的方式是维护一个
messages列表,每次全量发送 - token 有上限,需要主动管理:滑动窗口适合短期任务,摘要压缩适合长对话
- 混合策略(system + 摘要 + 最近 N 轮)是实际项目里最常用的
- 对 Agent 来说,上下文管理是多步推理的基础,直接影响推理质量
下一篇我们来解决另一个常见问题:让模型说人话 — Structured Output。模型的回复是自然语言,但我们的程序需要结构化数据。怎么让模型稳定输出 JSON?怎么处理格式不对的情况?下篇见。
觉得有用的话点个收藏,后续系列持续更新。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)