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?怎么处理格式不对的情况?下篇见。


觉得有用的话点个收藏,后续系列持续更新。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐