系列文章
本篇是 AI Agent 深度解析系列的第 26 篇。以下是系列文章导航:
- 理解 AI Agent 的大脑:ReAct 模式从入门到实战
- AI Agent 的工具箱:深入理解 Tool Use 与 Spring AI Function Calling 实战
- AI Agent 的记忆系统:从 ChatMemory 到持久化记忆的 Java 实战
- AI Agent 的规划大脑:从任务分解到自适应执行策略
- AI Agent 的灵魂对话:Prompt Engineering 系统提示词设计的艺术与工程
- MCP 模型上下文协议:AI 的万能接口与 MCP Server 实战
- 让 AI 学会”说人话”——Spring AI 结构化输出实战
- Embedding 向量化的魔法:从文本到向量的数学之旅与 Java 实战
- 从零理解 RAG:检索增强生成完整指南
- 当 RAG 遇到 Agent:Agentic RAG 的架构设计与 Java 实战
- 当 RAG 遇上知识图谱:GraphRAG 原理与 Java 实战
- AI Agent 的知识检索引擎:从向量搜索到智能检索策略的 Java 实战
- AI Agent 的推理引擎:从 Chain-of-Thought 到推理模型的深度解析与 Java 实战
- AI Agent 评估与优化:从基准测试到生产环境的质量守护实战
- AI Agent 的可观测性:从链路追踪到成本监控的 Java 实战
- AI Agent 的安全防线:Prompt 注入防御与生产级安全防护实战
- Spring AI 核心架构全解析:从 ChatModel 到 Advisor Chain 的设计哲学
- AI Agent 的流式响应与实时交互:从 SSE 到 WebSocket 的 Java 实战
- AI Agent 的工作流编排:从顺序链到自适应 DAG 的 Java 实战
- AI Agent 的多模态感知:从图片理解到语音交互的 Java 实战
- AI Agent 团队协作:多 Agent 系统架构设计与 Java 实战
- Agent 间如何对话:A2A 协议深度解析与 Java 实战
- AI Agent 的自我反思与经验学习:从错误中进化的 Java 实战
- AI Agent 的容错与韧性:从错误处理到生产级可靠性保障的 Java 实战
- 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 的上下文窗口,你也需要精心管理。原因有三:
- 成本:Token 数量直接决定 API 费用。128K 的上下文,哪怕输入只要 $0.01/1K tokens,一次调用也要 $1.28
- 性能:研究表明,模型对上下文中间位置的信息关注度最低(”Lost in the Middle”问题)。窗口越大,有效信息密度越低
- 延迟: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; private final int toolDefBudget = 2000; private final int historyBudget = 2500; private final int ragBudget = 1000; 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); 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 ); 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
|
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); 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
| 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); 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) { Message removed = messages.remove(1); currentTokens -= tokenEstimator.estimate(removed); 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 .defaultSystem("你是一个专业的客服Agent,擅长回答产品问题和处理订单。" + "回答要简洁专业,不确定的信息要明确告知用户。") .defaultAdvisors( new TokenBudgetAdvisor(8000), 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
|
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) { int currentTokens = estimateRequestTokens(request); if (currentTokens <= maxTokens) { return chain.nextAroundCall(request); } AdvisedRequest trimmedRequest = trimToFitBudget(request, currentTokens); return chain.nextAroundCall(trimmedRequest); }
private AdvisedRequest trimToFitBudget(AdvisedRequest request, int currentTokens) { 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
|
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
|
@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) { List<Double> queryEmbedding = embeddingModel.embed(userQuery).content(); 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; case "policy" -> age.toDays() > 7; case "order_status" -> true; default -> age.toDays() > 14; }; }
public void onNegativeFeedback(String cacheId) { } }
|
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() { 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。
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();
public AgentContext trimToBudget(AgentContext context, int tokenBudget) { int currentTokens = estimateTotal(context); if (currentTokens <= tokenBudget) { return context; } int overBudget = currentTokens - tokenBudget; int saved = 0; if (saved < overBudget) { int historySaved = trimHistory(context, overBudget - saved); saved += historySaved; } 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; 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
|
@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<>(); UserProfile profile = l3Memory.getProfile(userId); if (profile != null) { context.add(SystemMessage.from( "【用户画像】" + profile.toBriefString() )); } String sessionSummary = l2Memory.getSummary(sessionId); if (sessionSummary != null) { context.add(SystemMessage.from( "【本次会话摘要】" + sessionSummary )); } List<Message> recentMessages = l1Memory.get(sessionId); context.addAll(recentMessages); context.add(new UserMessage(currentQuery)); return budget.trim(context); }
public void afterTurn(String userId, String sessionId, List<Message> turnMessages) { l1Memory.add(sessionId, turnMessages); if (l1Memory.get(sessionId).size() > 10) { asyncCompress(sessionId); } 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
|
public class TokenEstimator {
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) { int roleTokens = 4; 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; private final ChatLanguageModel standardModel; private final ChatLanguageModel liteModel;
public ChatLanguageModel route(String query, ConversationContext context) { if (isSimpleQuery(query)) return liteModel; if (requiresReasoning(query, context)) return premiumModel; 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) public void processBatch() { List<LLMTask> batch = new ArrayList<>(); taskQueue.drainTo(batch, 20); if (batch.isEmpty()) return; String combinedPrompt = batch.stream() .map(LLMTask::getPrompt) .collect(Collectors.joining("\n---\n")); String combinedResult = batchModel.generate(combinedPrompt); distributeResults(batch, combinedResult); } }
|
总结:上下文工程的核心思维
回顾全文,上下文工程的本质是一个 资源分配问题:
- 窗口是稀缺资源:不管模型支持多大的窗口,成本和性能都要求你精打细算
- 分层管理是关键:System Prompt(固定)→ 工具(按需)→ RAG(按相关性)→ 历史(可压缩)
- 压缩不等于丢弃:用摘要压缩保留关键信息,用 记忆系统 持久化重要数据
- 缓存是最高效的优化:语义缓存可以消除 30-50% 的重复调用
- 不同场景用不同模型:不是所有请求都需要最贵的模型
上下文工程做得好不好,直接决定了你的 Agent 在生产环境中的表现——它是”能用”和”好用”之间的分水岭。
在下一篇文章中,我们将探讨 AI Agent 的成本控制与 Token 经济学——从计量计费到预算告警的完整方案。敬请期待。