AI Agent 的上下文工程与 Token 预算管理:从窗口压缩到成本优化的 Java 实战

系列文章

本篇是 AI Agent 深度解析系列的第 26 篇。以下是系列文章导航:

  1. 理解 AI Agent 的大脑:ReAct 模式从入门到实战
  2. AI Agent 的工具箱:深入理解 Tool Use 与 Spring AI Function Calling 实战
  3. AI Agent 的记忆系统:从 ChatMemory 到持久化记忆的 Java 实战
  4. AI Agent 的规划大脑:从任务分解到自适应执行策略
  5. AI Agent 的灵魂对话:Prompt Engineering 系统提示词设计的艺术与工程
  6. MCP 模型上下文协议:AI 的万能接口与 MCP Server 实战
  7. 让 AI 学会”说人话”——Spring AI 结构化输出实战
  8. Embedding 向量化的魔法:从文本到向量的数学之旅与 Java 实战
  9. 从零理解 RAG:检索增强生成完整指南
  10. 当 RAG 遇到 Agent:Agentic RAG 的架构设计与 Java 实战
  11. 当 RAG 遇上知识图谱:GraphRAG 原理与 Java 实战
  12. AI Agent 的知识检索引擎:从向量搜索到智能检索策略的 Java 实战
  13. AI Agent 的推理引擎:从 Chain-of-Thought 到推理模型的深度解析与 Java 实战
  14. AI Agent 评估与优化:从基准测试到生产环境的质量守护实战
  15. AI Agent 的可观测性:从链路追踪到成本监控的 Java 实战
  16. AI Agent 的安全防线:Prompt 注入防御与生产级安全防护实战
  17. Spring AI 核心架构全解析:从 ChatModel 到 Advisor Chain 的设计哲学
  18. AI Agent 的流式响应与实时交互:从 SSE 到 WebSocket 的 Java 实战
  19. AI Agent 的工作流编排:从顺序链到自适应 DAG 的 Java 实战
  20. AI Agent 的多模态感知:从图片理解到语音交互的 Java 实战
  21. AI Agent 团队协作:多 Agent 系统架构设计与 Java 实战
  22. Agent 间如何对话:A2A 协议深度解析与 Java 实战
  23. AI Agent 的自我反思与经验学习:从错误中进化的 Java 实战
  24. AI Agent 的容错与韧性:从错误处理到生产级可靠性保障的 Java 实战
  25. AI Agent 的上下文工程与 Token 预算管理(本文)

一个真实的”翻车”现场

你做了一个客服 Agent,Demo 效果惊艳——能准确回答产品问题,能记住用户偏好,还能调用订单系统查询物流。老板大手一挥:上线!

第一周,一切正常。
第二周,有用户反馈:”Agent 突然忘了我之前说的,明明前面聊了十分钟,后面又问我叫什么。”
第三周,运维告警:LLM API 账单比预算多了 3 倍。
第四周,Agent 开始报错:”maximum context length exceeded”。

你一脸懵地去查日志,发现:

  • 一个用户的对话历史积累了 50 轮,加上 RAG 检索的 5 段文档,再加上 8 个工具的定义,总 Token 数已经超过了模型的上下文窗口
  • 另一个用户每次都问同样的问题(”我的订单到哪了”),但 Agent 每次都完整调用一次 LLM + RAG,白白烧钱
  • System Prompt 里塞了 2000 个 Token 的产品知识,但其中 80% 和当前对话无关

问题的根源不是 Agent 不够智能,而是你没有做好”上下文工程”。

这篇文章,我们就来彻底搞懂:如何在有限的上下文窗口里,让 Agent 发挥最大的效能,同时把成本控制在预算之内。


第一章:上下文窗口——Agent 最稀缺的资源

1.1 什么是上下文窗口?

把 LLM 想象成一个工作台。上下文窗口就是这个工作台的面积——你只能在这张桌子上放这么多东西。放得太多,东西就会掉到地上(被截断);放得太少,桌子上空空荡荡,浪费了空间。

具体来说,上下文窗口是模型一次推理能够处理的 Token 总数,包括:

1
总 Token = System Prompt + 工具定义 + 对话历史 + RAG 检索结果 + 用户当前输入 + 模型输出

这不是一个”越大越好”的问题——即使模型支持 128K 甚至 1M 的上下文窗口,你也需要精心管理。原因有三:

  1. 成本:Token 数量直接决定 API 费用。128K 的上下文,哪怕输入只要 $0.01/1K tokens,一次调用也要 $1.28
  2. 性能:研究表明,模型对上下文中间位置的信息关注度最低(”Lost in the Middle”问题)。窗口越大,有效信息密度越低
  3. 延迟:Token 越多,推理时间越长。用户等 3 秒和等 10 秒,体验天差地别

1.2 Token 到底怎么算?

很多人以为 1 Token = 1 个单词,这不完全准确。Token 是模型的最小处理单元,取决于分词器(Tokenizer)的实现:

内容 大约 Token 数
“Hello world” 2
“今天天气真不错” 4-6(取决于分词器)
一个 Java 方法(50行) 150-250
一段 RAG 检索文档(500字) 300-500
一个工具定义(函数签名+描述) 100-300
System Prompt(1000字) 600-1000

关键认知:工具定义是隐藏的 Token 大户。如果你给 Agent 注册了 15 个工具,每个工具的定义(函数名、参数、描述)平均 200 Token,光工具定义就吃掉了 3000 Token。

1.3 主流模型的上下文窗口对比

模型 上下文窗口 输入价格(每百万Token) 输出价格(每百万Token)
GPT-4o 128K $2.50 $10.00
GPT-4o-mini 128K $0.15 $0.60
Claude 3.5 Sonnet 200K $3.00 $15.00
DeepSeek-V3 128K $0.27 $1.10
Qwen-Plus 131K $0.40 $1.20

假设一个客服 Agent 平均每次对话消耗 8K 输入 + 2K 输出,日均 1000 次对话:

  • 用 GPT-4o:每天 $100+,每月 $3000+
  • 用 GPT-4o-mini:每天 $6+,每月 $180+
  • 用 DeepSeek-V3:每天 $4.8+,每月 $145+

50 倍的成本差距。这就是为什么上下文管理不只是技术问题,更是商业问题。


第二章:Token 预算分配的艺术

2.1 预算分配模型

把上下文窗口想象成一个”预算”,你需要在多个”部门”之间分配:

1
2
3
4
5
6
7
总预算(Token)
├── 固定开支:System Prompt(必须有,不可压缩)
├── 固定开支:工具定义(必须有,按需加载)
├── 弹性开支:对话历史(可压缩、可裁剪)
├── 弹性开支:RAG 检索结果(按相关性筛选)
├── 弹性开支:当前用户输入(不可控)
└── 预留:模型输出空间

一个实际的预算分配方案(以 8K 窗口为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class TokenBudget {
// 总预算
private final int totalBudget = 8000;

// 固定开支
private final int systemPromptBudget = 1500; // System Prompt
private final int toolDefBudget = 2000; // 工具定义(约8-10个工具)

// 弹性开支
private final int historyBudget = 2500; // 对话历史
private final int ragBudget = 1000; // RAG 检索结果

// 预留
private final int outputBudget = 1000; // 模型输出
}

2.2 工具定义的按需加载

这是很多开发者忽略的一个优化点。你可能给 Agent 注册了 15 个工具,但在一次对话中,真正可能被用到的也许只有 3-5 个。

策略一:按对话阶段动态加载

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
39
@Component
public class DynamicToolLoader {

/**
* 根据对话阶段加载不同的工具集
* 例:用户在问产品问题 → 只加载知识库检索工具
* 用户要下单 → 加载订单创建工具
* 用户要查物流 → 加载物流查询工具
*/
public List<ToolDefinition> loadToolsForContext(String userIntent,
ConversationContext context) {
List<ToolDefinition> tools = new ArrayList<>();

// 基础工具(始终加载)
tools.add(knowledgeSearchTool); // RAG 知识库检索

// 意图相关的工具
switch (classifyIntent(userIntent)) {
case ORDER_QUERY:
tools.add(orderQueryTool);
tools.add(logisticsQueryTool);
break;
case ORDER_CREATE:
tools.add(orderCreateTool);
tools.add(inventoryCheckTool);
tools.add(paymentTool);
break;
case COMPLAINT:
tools.add(complaintCreateTool);
tools.add(escalationTool);
break;
case GENERAL_QA:
// 只加载知识库工具,不加载业务工具
break;
}

return tools;
}
}

策略二:工具描述压缩

每个工具的描述通常很长,但我们可以用更精简的格式:

1
2
3
4
5
6
7
8
9
10
11
// 不好的做法:冗长的描述
@Tool("""
这个工具用于查询用户的订单信息。你需要提供用户的用户ID和订单ID,
系统会返回订单的详细信息,包括订单状态、商品列表、收货地址等。
如果订单不存在,会返回错误信息。
""")
public OrderInfo queryOrder(String userId, String orderId) { ... }

// 好的做法:精简但完整的描述
@Tool("查询订单详情。入参:userId, orderId。返回:状态、商品、地址")
public OrderInfo queryOrder(String userId, String orderId) { ... }

这一招看似简单,但每个工具省 50 Token,10 个工具就省了 500 Token。


第三章:上下文压缩——核心策略与源码分析

3.1 策略一:滑动窗口(Sliding Window)

最直观的策略:只保留最近 N 轮对话。

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
/**
* 滑动窗口记忆策略
* 核心思想:最近的对话最重要,越远的越不重要
* 类比:你和朋友聊天,昨天聊了什么还记得,上周聊了什么就模糊了
*/
public class SlidingWindowChatMemory implements ChatMemory {

private final int maxMessages;
private final Deque<Message> window = new LinkedList<>();

public SlidingWindowChatMemory(int maxMessages) {
this.maxMessages = maxMessages;
}

@Override
public void add(String memoryId, List<Message> messages) {
window.addAll(messages);
// 窗口满了,移除最旧的消息
while (window.size() > maxMessages) {
window.pollFirst();
}
}

@Override
public List<Message> get(String memoryId) {
return new ArrayList<>(window);
}
}

问题:简单粗暴,但会丢失重要信息。如果用户在第 3 轮说了”我是 VIP 用户”,到了第 20 轮这条信息就被丢掉了。

3.2 策略二:摘要压缩(Summary Compression)

把旧的对话历史压缩成一段摘要,保留关键信息:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/**
* 摘要压缩记忆策略
* 核心思想:旧对话压缩成摘要,新对话保持原文
* 类比:你看完一本书,过段时间只记得主要情节,但最近读的章节还记得细节
*/
public class SummaryCompressionMemory implements ChatMemory {

private final ChatLanguageModel summarizer;
private final int retainRecentMessages;
private String compressedSummary = "";
private final List<Message> recentMessages = new ArrayList<>();

public SummaryCompressionMemory(ChatLanguageModel summarizer,
int retainRecentMessages) {
this.summarizer = summarizer;
this.retainRecentMessages = retainRecentMessages;
}

@Override
public void add(String memoryId, List<Message> messages) {
recentMessages.addAll(messages);

if (recentMessages.size() > retainRecentMessages * 2) {
// 需要压缩
compressOldMessages();
}
}

private void compressOldMessages() {
// 取出需要压缩的旧消息
List<Message> toCompress = recentMessages.subList(
0, recentMessages.size() - retainRecentMessages
);

// 让 LLM 生成摘要
String summaryPrompt = String.format(
"请将以下对话历史压缩为一段简洁的摘要,保留关键信息(用户身份、" +
"重要决策、未完成的任务)。不超过200字。\n\n对话历史:\n%s",
formatMessages(toCompress)
);

String newSummary = summarizer.generate(summaryPrompt);

// 与旧摘要合并
if (!compressedSummary.isEmpty()) {
compressedSummary = "【之前的对话摘要】" + compressedSummary +
"\n【最新对话摘要】" + newSummary;
} else {
compressedSummary = newSummary;
}

// 只保留最近的消息
recentMessages.clear();
recentMessages.addAll(
toCompress.subList(
Math.max(0, toCompress.size() - retainRecentMessages),
toCompress.size()
)
);
}

@Override
public List<Message> get(String memoryId) {
List<Message> result = new ArrayList<>();
if (!compressedSummary.isEmpty()) {
result.add(SystemMessage.from("【历史对话摘要】" + compressedSummary));
}
result.addAll(recentMessages);
return result;
}
}

源码解读:这个实现的关键在于”分层”——旧消息被压缩成摘要(System Message),新消息保持原文。这样既保留了关键上下文,又控制了 Token 数量。

3.3 策略三:Token 感知的滑动窗口

不是按消息数量滑动,而是按 Token 数量滑动——这才是真正精确的控制:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/**
* Token 感知的滑动窗口
* 核心思想:按 Token 预算裁剪,而不是按消息数量
* 好处:不管用户发长文还是短文,都能精确控制上下文大小
*/
public class TokenAwareWindowMemory implements ChatMemory {

private final ChatLanguageModel model;
private final int maxInputTokens;
private final List<Message> messages = new ArrayList<>();
private final TokenEstimator tokenEstimator;

public TokenAwareWindowMemory(ChatLanguageModel model, int maxInputTokens) {
this.model = model;
this.maxInputTokens = maxInputTokens;
this.tokenEstimator = new TokenEstimator();
}

@Override
public void add(String memoryId, List<Message> newMessages) {
messages.addAll(newMessages);

// 从最新的消息开始,向前计算 Token
int totalTokens = 0;
int cutoffIndex = messages.size();

for (int i = messages.size() - 1; i >= 0; i--) {
int msgTokens = tokenEstimator.estimate(messages.get(i));
if (totalTokens + msgTokens > maxInputTokens) {
cutoffIndex = i + 1;
break;
}
totalTokens += msgTokens;
}

// 移除超出预算的旧消息
if (cutoffIndex > 0 && cutoffIndex < messages.size()) {
List<Message> overflow = new ArrayList<>(
messages.subList(0, cutoffIndex)
);
messages.subList(0, cutoffIndex).clear();

// 把溢出的消息压缩成摘要
if (!overflow.isEmpty()) {
String summary = compressToSummary(overflow);
messages.add(0, SystemMessage.from(
"【对话历史摘要】" + summary
));
}
}
}

@Override
public List<Message> get(String memoryId) {
return Collections.unmodifiableList(messages);
}
}

3.4 LangChain4j 的 TokenWindowChatMemory 源码分析

LangChain4j 内置了 TokenWindowChatMemory,它是上述策略的生产级实现。让我们看看它的核心逻辑:

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
// LangChain4j 源码分析(简化版)
public class TokenWindowChatMemory extends AbstractChatMemory {

private final int maxTokens;
private final TokenEstimator tokenEstimator;
private final ChatMemoryListener listener; // 溢出消息的处理

@Override
protected void doAdd(String memoryId, List<Message> messages) {
List<Message> currentMessages = chatMemoryStore.getMessages(memoryId);
currentMessages.addAll(messages);

// 按 Token 裁剪
ensureCapacity(memoryId, currentMessages);

chatMemoryStore.updateMessages(memoryId, currentMessages);
}

private void ensureCapacity(String memoryId, List<Message> messages) {
int currentTokens = countTokens(messages);

while (currentTokens > maxTokens && messages.size() > 1) {
// 找到第二条消息(保留 System Message)
Message removed = messages.remove(1);
currentTokens -= tokenEstimator.estimate(removed);

// 通过 listener 处理被移除的消息
if (listener != null) {
listener.onEvicted(removed);
}
}
}
}

关键设计listener 模式。被移除的消息不是直接丢弃,而是通过 listener 回调,你可以选择将其压缩成摘要、存入长期记忆、或者写入数据库。这是一个非常优雅的扩展点。


第四章:Spring AI 实战——上下文管理与 Advisor Chain

4.1 Spring AI 的 Advisor 机制

Spring AI 的上下文管理通过 Advisor Chain 实现。每个 Advisor 是一个中间件,可以对请求和响应进行拦截和处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
public class AgentContextConfig {

@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder
// 1. 系统提示 Advisor(固定上下文)
.defaultSystem("你是一个专业的客服Agent,擅长回答产品问题和处理订单。" +
"回答要简洁专业,不确定的信息要明确告知用户。")
// 2. 上下文压缩 Advisor(弹性上下文)
.defaultAdvisors(
new TokenBudgetAdvisor(8000), // Token 预算控制
new ContextCompressionAdvisor(), // 上下文压缩
new RelevancyFilterAdvisor() // 相关性过滤
)
.build();
}
}

4.2 实现 TokenBudgetAdvisor

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
/**
* Token 预算控制 Advisor
* 职责:在发送给 LLM 之前,检查并裁剪上下文到预算范围内
*/
public class TokenBudgetAdvisor implements CallAroundAdvisor {

private final int maxTokens;
private final TokenEstimator tokenEstimator = new TokenEstimator();

public TokenBudgetAdvisor(int maxTokens) {
this.maxTokens = maxTokens;
}

@Override
public AdvisedResponse aroundCall(AdvisedRequest request, CallAroundAdvisorChain chain) {
// 1. 计算当前请求的 Token 数
int currentTokens = estimateRequestTokens(request);

if (currentTokens <= maxTokens) {
// 在预算内,直接放行
return chain.nextAroundCall(request);
}

// 2. 超出预算,需要裁剪
AdvisedRequest trimmedRequest = trimToFitBudget(request, currentTokens);

return chain.nextAroundCall(trimmedRequest);
}

private AdvisedRequest trimToFitBudget(AdvisedRequest request, int currentTokens) {
// 裁剪策略:优先保留 System Prompt 和当前用户输入
// 中间的对话历史和 RAG 结果可以被裁剪

List<Message> history = request.adviseContext().get("chat_memory");
if (history == null || history.isEmpty()) {
return request;
}

int overBudget = currentTokens - maxTokens;
int removedTokens = 0;
List<Message> trimmedHistory = new ArrayList<>(history);

// 从最旧的消息开始移除
while (removedTokens < overBudget && trimmedHistory.size() > 1) {
Message removed = trimmedHistory.remove(0);
removedTokens += tokenEstimator.estimate(removed);
}

// 更新请求上下文
Map<String, Object> newContext = new HashMap<>(request.adviseContext());
newContext.put("chat_memory", trimmedHistory);

return AdvisedRequest.from(request)
.advisors(request.advisors())
.adviseContext(newContext)
.build();
}

@Override
public String getName() {
return "TokenBudgetAdvisor";
}

@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE; // 最高优先级,最先执行
}
}

4.3 实现 ContextCompressionAdvisor

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* 上下文压缩 Advisor
* 职责:当对话历史过长时,自动压缩旧消息为摘要
*/
public class ContextCompressionAdvisor implements CallAroundAdvisor {

private final ChatLanguageModel summarizer;
private final int compressionThreshold; // 超过多少条消息触发压缩

public ContextCompressionAdvisor(ChatLanguageModel summarizer,
int compressionThreshold) {
this.summarizer = summarizer;
this.compressionThreshold = compressionThreshold;
}

@Override
public AdvisedResponse aroundCall(AdvisedRequest request,
CallAroundAdvisorChain chain) {
List<Message> history = request.adviseContext().get("chat_memory");

if (history == null || history.size() <= compressionThreshold) {
return chain.nextAroundCall(request);
}

// 需要压缩
int retainCount = compressionThreshold / 2;
List<Message> toCompress = history.subList(0, history.size() - retainCount);
List<Message> toRetain = history.subList(
history.size() - retainCount, history.size()
);

// 生成摘要
String summary = generateSummary(toCompress);

// 构建新的消息列表
List<Message> compressedHistory = new ArrayList<>();
compressedHistory.add(new SystemMessage("【历史摘要】" + summary));
compressedHistory.addAll(toRetain);

Map<String, Object> newContext = new HashMap<>(request.adviseContext());
newContext.put("chat_memory", compressedHistory);

return chain.nextAroundCall(
AdvisedRequest.from(request)
.adviseContext(newContext)
.build()
);
}

private String generateSummary(List<Message> messages) {
String prompt = "将以下对话压缩为摘要,保留:用户身份、关键决策、" +
"未完成任务、重要偏好。不超过150字。\n\n" +
formatMessages(messages);
return summarizer.generate(prompt);
}

@Override
public String getName() { return "ContextCompressionAdvisor"; }

@Override
public int getOrder() { return Ordered.HIGHEST_PRECEDENCE + 1; }
}

第五章:语义缓存——相同问题不问两次

5.1 为什么需要语义缓存?

在客服场景中,80% 的问题都是重复的:”怎么退货?””发票怎么开?””配送要几天?”

如果每次都调用 LLM,就是在烧钱。语义缓存的做法是:如果新问题和之前某个问题”语义相似”,直接返回缓存的答案。

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
* 语义缓存实现
* 核心思路:用户问题 → Embedding → 与缓存中的问题做相似度比对
* 如果相似度超过阈值 → 直接返回缓存答案
* 如果不相似 → 调用 LLM → 把问答对存入缓存
*/
@Component
public class SemanticCache {

private final EmbeddingModel embeddingModel;
private final VectorStore vectorStore;
private final double similarityThreshold;

public SemanticCache(EmbeddingModel embeddingModel,
VectorStore vectorStore,
@Value("${cache.similarity-threshold:0.92}") double threshold) {
this.embeddingModel = embeddingModel;
this.vectorStore = vectorStore;
this.similarityThreshold = threshold;
}

/**
* 查找缓存的回复
*/
public Optional<CachedResponse> findCachedResponse(String userQuery) {
// 1. 生成查询的 Embedding
List<Double> queryEmbedding = embeddingModel.embed(userQuery).content();

// 2. 在向量库中搜索相似的问题
SearchRequest request = SearchRequest.builder()
.query(userQuery)
.topK(3)
.similarityThreshold(similarityThreshold)
.build();

List<SearchResult> results = vectorStore.similaritySearch(request);

if (!results.isEmpty()) {
SearchResult bestMatch = results.get(0);
return Optional.of(new CachedResponse(
bestMatch.text(),
bestMatch.metadata().get("answer"),
bestMatch.score()
));
}

return Optional.empty();
}

/**
* 缓存问答对
*/
public void cacheResponse(String question, String answer, String agentId) {
Document doc = Document.builder()
.text(question)
.metadata(Map.of(
"answer", answer,
"agentId", agentId,
"timestamp", Instant.now().toString(),
"hitCount", "0"
))
.build();

vectorStore.add(List.of(doc));
}
}

5.2 缓存失效策略

缓存不能永远不过期。产品更新了,政策变化了,缓存的答案可能就过时了:

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
@Component
public class CacheInvalidationStrategy {

/**
* 基于时间的失效
*/
public boolean shouldExpire(CachedResponse cached) {
Duration age = Duration.between(cached.cachedAt(), Instant.now());

// 不同类型的问题,缓存有效期不同
return switch (cached.category()) {
case "product_info" -> age.toDays() > 30; // 产品信息:30天
case "policy" -> age.toDays() > 7; // 政策类:7天
case "order_status" -> true; // 订单状态:不缓存
default -> age.toDays() > 14; // 默认:14天
};
}

/**
* 基于反馈的失效
* 如果用户对缓存的回答点了"不满意",标记缓存失效
*/
public void onNegativeFeedback(String cacheId) {
// 降低缓存的置信度
// 连续3次负反馈 → 删除缓存
}
}

5.3 缓存预热

上线前,把高频问题的答案预先缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Component
public class CacheWarmer {

@EventListener(ApplicationReadyEvent.class)
public void warmUpCache() {
// 从知识库加载 FAQ
List<FAQ> faqs = faqRepository.findAll();

for (FAQ faq : faqs) {
semanticCache.cacheResponse(
faq.question(),
faq.answer(),
"system-warmup"
);
}

log.info("缓存预热完成,加载 {} 条 FAQ", faqs.size());
}
}

实际效果:在一个日均 1000 次对话的客服系统中,语义缓存命中率通常在 30-50%。按每次 LLM 调用 $0.02 计算,每天可以节省 $6-10,每月节省 $180-300。


第六章:动态上下文裁剪——RAG + Memory + Tools 的优先级管理

6.1 上下文的”三明治”结构

一个典型的 Agent 请求,上下文是这样分层的:

1
2
3
4
5
6
7
8
9
10
11
┌─────────────────────────────────┐
│ System Prompt(顶层) │ ← 最高优先级,不能裁剪
├─────────────────────────────────┤
│ 工具定义(第二层) │ ← 按需加载,可精简
├─────────────────────────────────┤
│ RAG 检索结果(第三层) │ ← 按相关性筛选,可降级
├─────────────────────────────────┤
│ 对话历史(第四层) │ ← 可压缩、可裁剪
├─────────────────────────────────┤
│ 用户当前输入(底层) │ ← 不可裁剪
└─────────────────────────────────┘

6.2 优先级裁剪算法

当总 Token 超出预算时,按优先级从低到高裁剪:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@Component
public class PriorityBasedContextTrimmer {

private final TokenEstimator tokenEstimator = new TokenEstimator();

/**
* 按优先级裁剪上下文到预算范围内
* 裁剪顺序:对话历史 → RAG 结果 → 工具定义 → System Prompt(不动)
*/
public AgentContext trimToBudget(AgentContext context, int tokenBudget) {
int currentTokens = estimateTotal(context);

if (currentTokens <= tokenBudget) {
return context; // 在预算内
}

int overBudget = currentTokens - tokenBudget;
int saved = 0;

// 第一步:裁剪对话历史(保留最近 N 轮)
if (saved < overBudget) {
int historySaved = trimHistory(context, overBudget - saved);
saved += historySaved;
}

// 第二步:降低 RAG 结果质量(保留高相关性,丢弃低相关性)
if (saved < overBudget) {
int ragSaved = trimRagResults(context, overBudget - saved);
saved += ragSaved;
}

// 第三步:卸载不必要的工具
if (saved < overBudget) {
int toolSaved = trimToolDefinitions(context, overBudget - saved);
saved += toolSaved;
}

return context;
}

private int trimHistory(AgentContext context, int targetSaving) {
List<Message> history = context.getChatHistory();
int saved = 0;

// 从最旧的消息开始移除
while (saved < targetSaving && history.size() > 2) {
Message removed = history.remove(0);
saved += tokenEstimator.estimate(removed);
}

return saved;
}

private int trimRagResults(AgentContext context, int targetSaving) {
List<Document> ragResults = context.getRagDocuments();
int saved = 0;

// 按相关性排序,移除低相关性的结果
ragResults.sort(Comparator.comparingDouble(Document::score).reversed());

while (saved < targetSaving && ragResults.size() > 1) {
Document removed = ragResults.remove(ragResults.size() - 1);
saved += tokenEstimator.estimate(removed.text());
}

return saved;
}

private int trimToolDefinitions(AgentContext context, int targetSaving) {
List<ToolDefinition> tools = context.getToolDefinitions();
int saved = 0;

// 移除当前对话中不太可能用到的工具
// 可以用意图分类来判断哪些工具不需要
List<ToolDefinition> essentialTools = filterEssentialTools(tools, context);

for (ToolDefinition tool : tools) {
if (!essentialTools.contains(tool)) {
saved += tokenEstimator.estimate(tool.description());
}
}

context.setToolDefinitions(essentialTools);
return saved;
}
}

第七章:LangChain4j 高级实战——智能上下文管理

7.1 自定义 ChatMemoryProvider

LangChain4j 允许你为每个用户会话创建独立的 Memory 实例:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Configuration
public class MemoryProviderConfig {

@Bean
public ChatMemoryProvider chatMemoryProvider() {
return memoryId -> TokenWindowChatMemory.builder()
.maxTokens(4000)
.tokenEstimator(TikTokenTokenEstimator.openAiCl100k())
.chatMemoryStore(new RedisChatMemoryStore(redisTemplate))
.addCustomMessageWindow(new SmartMessageWindow())
.build();
}
}

/**
* 智能消息窗口:根据消息重要性决定保留哪些消息
*/
public class SmartMessageWindow implements MessageWindow {

@Override
public List<Message> selectMessages(List<Message> allMessages, int tokenBudget) {
List<ScoredMessage> scored = allMessages.stream()
.map(m -> new ScoredMessage(m, scoreMessage(m)))
.sorted(Comparator.comparingDouble(ScoredMessage::score).reversed())
.collect(Collectors.toList());

List<Message> selected = new ArrayList<>();
int usedTokens = 0;

for (ScoredMessage sm : scored) {
int tokens = estimateTokens(sm.message());
if (usedTokens + tokens <= tokenBudget) {
selected.add(sm.message());
usedTokens += tokens;
}
}

// 恢复原始顺序
selected.sort(Comparator.comparingInt(allMessages::indexOf));
return selected;
}

private double scoreMessage(Message message) {
double score = 0.5; // 基础分

// 最近的消息加分
// ...

// 包含关键信息的消息加分(用户名、决策、偏好)
String content = message.text();
if (content.contains("我叫") || content.contains("我是")) score += 0.3;
if (content.contains("想要") || content.contains("决定")) score += 0.2;

// System Message 最高优先级
if (message instanceof SystemMessage) score += 1.0;

return score;
}
}

7.2 多级记忆架构

记忆系统 的概念和上下文管理结合起来:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* 多级记忆管理器
* L1: 工作记忆(当前对话的最近几轮,Token 感知)
* L2: 短期记忆(本次会话的摘要,自动压缩)
* L3: 长期记忆(跨会话的用户画像、偏好,持久化存储)
*/
@Component
public class MultiLevelMemoryManager {

private final ChatMemory l1Memory; // 工作记忆
private final SummaryStore l2Memory; // 短期记忆
private final UserProfileStore l3Memory; // 长期记忆
private final TokenBudget budget;

public List<Message> buildContext(String userId, String sessionId,
String currentQuery) {
List<Message> context = new ArrayList<>();

// L3: 长期记忆(用户画像,优先级最高)
UserProfile profile = l3Memory.getProfile(userId);
if (profile != null) {
context.add(SystemMessage.from(
"【用户画像】" + profile.toBriefString()
));
}

// L2: 短期记忆(会话摘要)
String sessionSummary = l2Memory.getSummary(sessionId);
if (sessionSummary != null) {
context.add(SystemMessage.from(
"【本次会话摘要】" + sessionSummary
));
}

// L1: 工作记忆(最近对话)
List<Message> recentMessages = l1Memory.get(sessionId);
context.addAll(recentMessages);

// 当前用户输入
context.add(new UserMessage(currentQuery));

// 检查总 Token 是否超预算
return budget.trim(context);
}

/**
* 对话结束后,更新各级记忆
*/
public void afterTurn(String userId, String sessionId,
List<Message> turnMessages) {
// L1: 更新工作记忆
l1Memory.add(sessionId, turnMessages);

// L2: 异步压缩为摘要
if (l1Memory.get(sessionId).size() > 10) {
asyncCompress(sessionId);
}

// L3: 提取用户偏好(每 5 轮做一次)
extractUserPreferences(userId, turnMessages);
}
}

第八章:横向对比——两大框架的上下文管理策略

8.1 LangChain4j vs Spring AI

特性 LangChain4j Spring AI
记忆管理 ChatMemory 接口 + 多种实现 ChatMemory Advisor
Token 控制 TokenWindowChatMemory 内置 需要自定义 Advisor
压缩策略 滑动窗口 + Listener 回调 Advisor Chain 链式处理
存储后端 内存/文件/Redis/数据库 依赖 ChatMemory 实现
扩展性 接口抽象,灵活但需自己实现 Advisor 模式,标准化程度高
工具裁剪 需手动实现 可通过 Advisor 自动化

8.2 如何选择?

  • 如果你用 Spring Boot 全家桶:Spring AI 的 Advisor Chain 和你的项目天然契合,上下文管理作为 Advisor 实现,逻辑清晰
  • 如果你需要精细的 Token 控制:LangChain4j 的 TokenWindowChatMemory 开箱即用,内置 Token 估算
  • 如果你需要复杂的状态管理:两者都需要自定义实现,但 Spring AI 的 Advisor 模式更容易组合多种策略

第九章:生产环境最佳实践

9.1 Token 估算的准确性

不要用 字符数 / 4 来估算 Token 数——这在中文场景下误差很大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 准确的 Token 估算
* 不要偷懒用字符数除以某个系数,中文和英文的 Token 密度差异很大
*/
public class TokenEstimator {

// 使用 tiktoken 库做精确计算
private final Encoding encoding;

public TokenEstimator() {
this.encoding = EncodingFactory.getEncoding("cl100k_base");
}

public int estimate(String text) {
return encoding.encode(text).size();
}

public int estimate(Message message) {
// 角色标记也消耗 Token
int roleTokens = 4; // <|start|>assistant<|message|>
return roleTokens + estimate(message.text());
}
}

9.2 监控与告警

结合 可观测性建设,监控上下文管理的效果:

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
@Component
public class ContextMetrics {

private final MeterRegistry registry;

public void recordContextBuild(String agentId, ContextBuildMetrics metrics) {
// 上下文大小分布
registry.gauge("agent.context.tokens",
Tags.of("agent", agentId),
metrics.totalTokens());

// 各部分占比
registry.gauge("agent.context.system_prompt_ratio",
Tags.of("agent", agentId),
(double) metrics.systemPromptTokens() / metrics.totalTokens());

// 缓存命中率
registry.counter("agent.cache.hit",
Tags.of("agent", agentId, "hit", String.valueOf(metrics.cacheHit())))
.increment();

// 压缩触发次数
if (metrics.compressionTriggered()) {
registry.counter("agent.context.compression",
Tags.of("agent", agentId)).increment();
}
}
}

9.3 常见陷阱

陷阱一:System Prompt 膨胀

开发者倾向于在 System Prompt 里塞越来越多的规则和知识。结果 System Prompt 本身就占了 3000 Token,留给对话和 RAG 的空间越来越少。

解决方案:System Prompt 只放”规则”,不放”知识”。知识通过 RAG 动态加载。

陷阱二:RAG 结果冗余

RAG 检索返回了 5 段文档,但其中 3 段说的是同一件事的不同时期的版本。

解决方案:检索后做去重(按内容相似度过滤),只保留最新、最相关的版本。

陷阱三:工具描述和实际功能不一致

工具描述说”返回订单详情”,但实际返回了一大段 JSON 日志。模型基于描述理解的输出格式和实际格式不同,导致后续推理出错。

解决方案:工具描述中明确说明返回格式,或者在工具返回后做格式化处理。

陷阱四:压缩丢失关键信息

摘要压缩把”用户说他是 VIP”压缩成了”用户咨询了问题”——关键的用户身份信息丢失了。

解决方案:压缩 Prompt 中明确要求保留”用户身份、关键决策、重要偏好”。或者用 长期记忆 单独持久化用户画像。


第十章:成本优化的实战策略

10.1 模型路由——不同场景用不同模型

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
/**
* 智能模型路由器
* 核心思想:不是所有请求都需要最贵的模型
*/
@Component
public class ModelRouter {

private final ChatLanguageModel premiumModel; // GPT-4o
private final ChatLanguageModel standardModel; // DeepSeek-V3
private final ChatLanguageModel liteModel; // GPT-4o-mini

public ChatLanguageModel route(String query, ConversationContext context) {
// 简单问题(闲聊、FAQ)→ lite 模型
if (isSimpleQuery(query)) return liteModel;

// 需要推理的问题(分析、规划)→ premium 模型
if (requiresReasoning(query, context)) return premiumModel;

// 默认 → standard 模型
return standardModel;
}

private boolean isSimpleQuery(String query) {
// 短问题 + 高频模式 → 简单问题
return query.length() < 50 && matchesCommonPattern(query);
}

private boolean requiresReasoning(String query, ConversationContext context) {
// 多步骤问题、需要分析的问题 → 需要推理
return query.contains("分析") || query.contains("对比") ||
query.contains("为什么") || context.getToolCallCount() > 3;
}
}

实际效果:70% 的简单查询用 lite 模型处理,20% 用 standard,10% 用 premium。平均成本降低 60%。

10.2 批处理与异步化

不是所有 LLM 调用都需要实时响应:

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
/**
* 异步批处理队列
* 适用场景:摘要压缩、用户画像提取、内容审核
*/
@Component
public class AsyncLLMBatchProcessor {

private final ChatLanguageModel batchModel;
private final BlockingQueue<LLMTask> taskQueue = new LinkedBlockingQueue<>(1000);

@Async
public CompletableFuture<String> submitBatchTask(LLMTask task) {
taskQueue.offer(task);
return task.getFuture();
}

@Scheduled(fixedDelay = 5000) // 每5秒处理一次
public void processBatch() {
List<LLMTask> batch = new ArrayList<>();
taskQueue.drainTo(batch, 20); // 每批最多20个任务

if (batch.isEmpty()) return;

// 合并为单个请求(利用 LLM 的多轮能力)
String combinedPrompt = batch.stream()
.map(LLMTask::getPrompt)
.collect(Collectors.joining("\n---\n"));

String combinedResult = batchModel.generate(combinedPrompt);

// 分发结果
distributeResults(batch, combinedResult);
}
}

总结:上下文工程的核心思维

回顾全文,上下文工程的本质是一个 资源分配问题

  1. 窗口是稀缺资源:不管模型支持多大的窗口,成本和性能都要求你精打细算
  2. 分层管理是关键:System Prompt(固定)→ 工具(按需)→ RAG(按相关性)→ 历史(可压缩)
  3. 压缩不等于丢弃:用摘要压缩保留关键信息,用 记忆系统 持久化重要数据
  4. 缓存是最高效的优化:语义缓存可以消除 30-50% 的重复调用
  5. 不同场景用不同模型:不是所有请求都需要最贵的模型

上下文工程做得好不好,直接决定了你的 Agent 在生产环境中的表现——它是”能用”和”好用”之间的分水岭。

在下一篇文章中,我们将探讨 AI Agent 的成本控制与 Token 经济学——从计量计费到预算告警的完整方案。敬请期待。