Token 费用失控、VIP 用户体验一样烂:Context Engineering 才是关键

0 阅读21分钟

Context Engineering:让 Agent 每一轮拿到正确上下文

票小蜜上线第一周,出现了一个奇怪的问题:VIP 用户和免费用户拿到了几乎一模一样的回答,口吻也完全一样——"您好,请问有什么可以帮助您的?"

产品经理问:能不能识别用户等级,给 VIP 用户更主动、更贴心的服务?

听起来很简单。但系统里 systemPrompt 是构建期写死的,它不知道当前请求是哪个用户发来的。更根本的问题是:Agent 每次推理时,LLM 到底能"看到"什么?是谁决定了这个"看到"?

这就是 Context Engineering 要解决的问题。

系列目标:从零构建机票客服型 Agent「票小蜜」 本篇位置:第 11 章 / Context Engineering 前置知识:第 10 章《ReactAgent 运行时:State、Hooks 与 Interceptors》


理论篇

一、Prompt Engineering 解决不了的问题

传统的 Prompt Engineering 关注的是"怎么写出好的提示词"。它的隐含假设是:提示词写好了,问题就解决了。

这个假设在 Chatbot 时代基本成立。但 Agent 打破了它。

Agent 每一轮推理的上下文并不是静止的——它在随着执行进程动态变化:

1 轮:系统提示 + 用户输入 + 可用工具列表
第 2 轮:+ 第 1 轮工具调用结果(查到了 CA1234 航班)
第 3 轮:+ 对比分析结果 + 余票信息
第 4 轮:+ 政策查询结果(退改签规定)+ Token 预算已用 60%

每一轮 LLM 看到的东西都不同,Agent 的决策因此也不同。Context Engineering 就是管理这个动态变化过程的工程方法——控制什么信息在什么时候、以什么形式进入 LLM 的上下文窗口。

Andrej Karpathy 对这个概念有一个简洁的表述:

"Context Engineering 是一门精妙的科学与工程,它关注如何在上下文窗口中填充恰好正确的信息,使 LLM 有可能做出合理的下一步行动。"

关键词:恰好正确。太多会触发 Token 上限,太少会让模型做出错误决策。

更深一层的理解:上下文不只是"提供信息"——它在重塑模型对下一步行动的概率分布。把所有相关信息堆进去,并不等于模型会做出正确判断。

Stanford 2023 年的研究发现,当上下文过长时,LLM 对中间段信息的注意力会显著下降("lost in the middle" 效应),开头和结尾的信息权重更高。同样的信息,放在不同位置,效果差异可以达到 20% 以上。

这意味着 Context Engineering 不只是管理"放什么",还要管理"怎么放"。


二、上下文的四个数据层

Spring AI Alibaba 的 ReactAgent 处理的上下文有四个来源,它们进入 LLM 窗口的方式各不相同:

在这里插入图片描述

数据层来源进入方式生命周期
① 静态上下文systemPrompt、工具定义直接进窗口(每次都有)构建期固定
② 会话历史OverAllState messages直接进窗口(累积)跨轮持久
③ 检索内容RAG / 工具结果直接进窗口(追加到 messages)按需注入
④ 动态注入RunnableConfig.metadata → Hook通过 ToolContext 或 messages每次请求可变

注意第四层的关键区别:OverAllState 的自定义 key(如 userTierllmCallCount)本身不进 LLM 窗口。它们是 Agent 内部的"业务数据库",需要通过特定路径(ToolContext 或写入 messages)才能影响 LLM 的判断。


三、框架视角:三种上下文类型

框架官方把"你能控制什么"归纳为三类,区分瞬态(单次调用可见)与持久(写入 OverAllState 跨轮保存):

上下文类型你控制的内容是否持久
模型上下文(Model Context)每次模型调用传入的内容:系统提示、消息历史、工具列表、输出格式瞬态
工具上下文(Tool Context)工具能读写的内容:OverAllState、RunnableConfig.metadata、extraState 回写持久
生命周期上下文(Lifecycle Context)Hook 在调用前后执行的操作:摘要、限流、防护栏、日志持久

这三类的本质区别不是"用哪个类",而是你的操作有没有写入 OverAllState

  • 写入了 OverAllState → 持久,MemorySaver 会快照,后续轮次都能读到
  • 没有写入 OverAllState → 瞬态,下一轮 LLM 看不到,状态不变

实际工程里,这个区别直接决定了 Token 费用——把不该持久的东西写进 OverAllState,等于每轮对话都带着它进窗口。


四、为什么第四层的注入路径很重要

一个典型的误解:把 userTier=vip 写进 OverAllState,以为 LLM 就能"知道"这个用户是 VIP。

不对。OverAllState 的自定义 key 对 LLM 是不可见的。LLM 只看 messages 列表(包含系统提示、对话历史、工具结果)。

要让 userTier 真正影响 Agent 的行为,有两条路:

路径 A:通过 ToolContext 影响工具行为

AgentLlmNode 在调用工具时,会把 RunnableConfig.metadata 透传给 ToolContext。工具函数读到 userTier,可以返回差异化的数据——VIP 用户看到更多航班信息、更低的舱位锁定费等。

// 工具函数:读取 ToolContext 提供差异化服务
public String searchFlights(FlightQuery query, ToolContext ctx) {
    String tier = (String) ctx.getContext().get("userTier");
    if ("vip".equals(tier)) {
        // VIP 用户:返回头等舱+商务舱+经济舱全价格段
        return mockService.searchAllCabins(query);
    }
    // 普通用户:只返回经济舱
    return mockService.searchEconomy(query);
}

路径 B:ModelInterceptor 临时修改——LLM 看到,但不写入 OverAllState

如果需要告诉 LLM"这个用户是 VIP,用更主动的语气",又不想这条信息永久留在 OverAllState 里,ModelInterceptor 正是为这个场景设计的。

// ModelInterceptor:只改这一次调用,不污染 OverAllState
class UserTierPromptInterceptor extends ModelInterceptor {
    @Override
    public ModelResponse interceptModel(ModelRequest request, ModelCallHandler handler) {
        // 从 RunnableConfig.metadata 读取当次请求的 userTier
        String userTier = (String) request.getContext()
                .getOrDefault("userTier", "free");

        String extra = "vip".equals(userTier)
                ? "\n【本次服务】该用户为 VIP,请主动推荐升舱选项,语气更贴心。"
                : "";

        if (!extra.isEmpty()) {
            SystemMessage enhanced = request.getSystemMessage() == null
                    ? new SystemMessage(extra.strip())
                    : new SystemMessage(request.getSystemMessage().getText() + extra);
            request = ModelRequest.builder(request).systemMessage(enhanced).build();
        }
        return handler.call(request);
    }

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

关键点:ModelInterceptor 改的是发送给 LLM 的 request,不回写 OverAllState。这次调用结束后,OverAllState 里的 messages 不含这条增强内容——下一轮对话不会堆积,Token 不膨胀。

瞬态 vs 持久的工程判断ModelInterceptor 只影响单次模型调用(瞬态);ModelHook.beforeModel() 返回的 Map 会写入 OverAllState(持久)。选哪个,取决于你是否需要这条信息"永远留下来"。绝大多数"当次请求的用户背景"用瞬态就够了,乱用持久会让 Token 失控。


路径 C:Hook beforeModel() 追加 messages——LLM 看到,且持久写入 OverAllState

如果确实需要把某条信息作为"永久上下文"保留(比如第一轮用户报的乘客姓名,后续轮次都要记住),才用 Hook beforeModel() 追加进 messages

// Hook:追加 messages,会被 AppendStrategy 持久化
@Override
public CompletableFuture<Map<String, Object>> beforeModel(
        OverAllState state, RunnableConfig config) {
    // 注意:返回 Map 里如果含 "messages" key,
    // AppendStrategy 会把它追加进 OverAllState,后续轮次都看得到
    return CompletableFuture.completedFuture(
        Map.of("importantContext", "乘客:张三,需要靠窗座位")
    );
}

由于 messages 使用 AppendStrategy,这条注入的消息会持久化——多轮对话后会堆积,必须配合 ContextEditingInterceptor 清理,否则 Token 随轮次线性膨胀。

三条路的选择原则

  • 影响工具行为(差异化数据、差异化查询范围)→ 路径 A:ToolContext,零 Token 开销,LLM 不感知
  • 需要 LLM 本次调用知道某个信息,但不需要永久保留 → 路径 B:ModelInterceptor,瞬态,不膨胀
  • 需要某条信息作为持久对话上下文 → 路径 C:Hook beforeModel,有持久化和膨胀风险,慎用

在这里插入图片描述

在票小蜜当前场景里,userTier 用于 Hook 限流和工具差异化,LLM 不需要直接知道等级——选路径 A 就够了。若未来需要 LLM 改变语气,路径 B(ModelInterceptor)是比路径 C 更安全的选择。


五、动态上下文注入:完整链路

理解了两条路之后,来看票小蜜里的实际流程:

在这里插入图片描述

核心链路是:

HTTP 请求参数 userTier=vip
  ↓
AgentController: RunnableConfig.addMetadata("userTier", "vip")
  ↓
ContextEnrichmentHook (BEFORE_MODEL):
  config.metadata("userTier") → 读到 "vip"
  → 写入 OverAllState: {userTier: "vip", timeOfDay: "afternoon"}
  ↓
AgentLlmNode:
  metadata 透传给 ToolContext(工具函数可读取)
  OverAllState 由 UserTierLimitHook 读取做差异化限流

为什么要经过 ContextEnrichmentHook 写入 OverAllState?

因为 RunnableConfig.metadata 是请求级的,每次 call() 都需要重新传入,不会自动跨轮持久。通过 Hook 写入 OverAllState,MemorySaver 会把 userTier 快照下来——第二轮对话时即使 Controller 没有显式传入,OverAllState 里仍然有值。



实战篇

六、实践:ticket-agent 第 11 章升级

6.1 ContextEnrichmentHook

@Component
@HookPositions({HookPosition.BEFORE_MODEL})
public class ContextEnrichmentHook extends ModelHook {

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

    @Override
    public Map<String, KeyStrategy> getKeyStrategys() {
        return Map.of(
            "userTier", new ReplaceStrategy(),
            "timeOfDay", new ReplaceStrategy()
        );
    }

    @Override
    public CompletableFuture<Map<String, Object>> beforeModel(
            OverAllState state, RunnableConfig config) {

        // 优先读请求级 metadata,回退到 OverAllState,再回退默认值
        String userTier = config.metadata("userTier", new TypeRef<String>() {})
                .orElseGet(() -> (String) state.value("userTier").orElse("free"));

        int hour = LocalTime.now().getHour();
        String timeOfDay = hour < 8 ? "early_morning"
                : hour < 12 ? "morning"
                : hour < 18 ? "afternoon"
                : "evening";

        return CompletableFuture.completedFuture(Map.of(
                "userTier", userTier,
                "timeOfDay", timeOfDay
        ));
    }
}

三个设计决策的解释

  1. 优先 metadata 而不是 OverAllState:metadata 是当前请求的"新鲜"值,OverAllState 可能是上一轮的旧值。orElseGet 确保:有新值用新值,没有新值用历史值,都没有用默认值。
  2. 同时写 timeOfDay:时间段信息当前只被工具读取(比如非工作时间提示"建议明天工作时间再操作"),但后续业务逻辑(工作时间优先人工审批、非工作时间走自动化)都需要它。
  3. 不写 userContext 字符串:早期版本把 userTier + timeOfDay 拼成一个字符串写进 OverAllState。这是个错误——结构化数据应该保持结构,拼成字符串反而失去了可编程性。

6.2 AgentController 注入用户等级

@GetMapping("/chat")
public String chat(
        @RequestParam String q,
        @RequestParam(defaultValue = "default") String sessionId,
        @RequestParam(defaultValue = "free") String userTier) throws GraphRunnerException {

    // Context Engineering 入口:把请求级上下文注入 RunnableConfig.metadata
    RunnableConfig runnableConfig = RunnableConfig.builder()
            .threadId(sessionId)
            .addMetadata("userTier", userTier)
            .build();

    AssistantMessage response = ticketAgent.call(q, runnableConfig);
    return response.getText();
}

生产注意userTier 不能从请求参数读取,应该从 JWT Token 或 Session 中解析。此处是课程演示简化版本。

6.3 AgentConfig Hook 链更新

// Hook 执行顺序:
// 1. contextEnrichmentHook: 读 metadata → 写 OverAllState(上下文注入)
// 2. userTierLimitHook:     读 OverAllState.userTier → 差异化限流
// 3. limitHook:             绝对上限兜底
// 4. summarizationHook:     Token 压缩
// 5. metricsHook:           观测打点
.hooks(List.of(contextEnrichmentHook, userTierLimitHook, limitHook, summarizationHook, metricsHook))

顺序很关键:contextEnrichmentHook 必须在 userTierLimitHook 之前。后者从 OverAllState 读 userTier,如果前者还没写进去,读到的是 null/默认值,限流等级就会错。

6.4 工具层回写 OverAllState:extraState

前面路径 A 讲的是"工具读 OverAllState"——但工具也可以写回 OverAllState。ToolContext 里有一个特殊的 extraState Map,放进去的值会在工具执行后被框架自动合并到 OverAllState:

// 工具函数:读取上下文,并把结果写回 OverAllState
@Override
public BookingResponse apply(BookingRequest request, ToolContext toolContext) {
    // 读取当次请求的上下文
    RunnableConfig config = (RunnableConfig) toolContext.getContext().get("config");
    String userTier = config.metadata("userTier", new TypeRef<String>() {}).orElse("free");

    // 执行业务逻辑
    String orderId = bookingService.book(request, userTier);

    // 把结果写回 OverAllState,后续 Hook 和工具都能读到
    @SuppressWarnings("unchecked")
    Map<String, Object> extraState =
            (Map<String, Object>) toolContext.getContext().get("extraState");
    extraState.put("lastOrderId", orderId);
    extraState.put("lastBookingStatus", "PENDING_APPROVAL");

    return new BookingResponse(orderId, "订单已创建,等待审批");
}

extraState 和 Hook 返回的 Map 效果一致——都会被框架合并进 OverAllState。区别在于触发时机:Hook 是在模型调用前后执行,extraState 是在工具执行后立即生效,下一轮 Agent 循环就能读到。

什么时候用 extraState?

  • 工具产生了业务关键数据(如订单 ID、审批状态),后续工具或 Hook 需要读取
  • 不想通过 LLM 中转(工具结果 → LLM 推理 → 下一个工具),而是直接把数据留在状态里
  • 典型场景:订票工具写入 lastOrderId,审批 Hook 读取后决定是否触发 HITL

七、静态上下文的设计原则

不是所有上下文都需要动态注入。静态上下文(systemPrompt)也有自己的工程考量。

票小蜜当前的 systemPrompt 包含:

你是机票客服型智能体「票小蜜」。

执行规则:
1. 用户给的是目标,不一定是完整参数
2. 如果查询航班所需信息不完整,先追问缺失字段
...

当前日期:2026-03-31

最后一行 当前日期 是用 Java 的 .formatted(LocalDate.now()) 在构建期注入的。这算是"半静态"——应用启动时确定,运行期不变。

什么应该放在 systemPrompt?

放进 systemPrompt不放进 systemPrompt
Agent 的角色和人设用户等级相关的服务差异
不变的执行规则和工作方式会变化的业务状态
工具使用策略(先查再比)当前请求的上下文信息
启动时就能确定的信息需要运行时计算的数据

把不该放的东西塞进 systemPrompt,会带来两个问题:

  1. 维护成本:每次业务规则变化都要重启应用
  2. Token 浪费:VIP 用户的专属规则对普通用户完全无效,但每次都占用 Token

八、上下文工程的核心 trade-off

Context Engineering 的本质是一道优化题:

目标函数:让 LLM 在当前 Token 预算内,拿到做出正确决策所需的最精准信息。

两种失败模式:

上下文太多(Token 膨胀)

  • 症状:每轮对话的 Token 消耗随会话轮次线性增长,到第 10 轮时单次调用费用是第 1 轮的 10 倍
  • 原因:把所有工具结果都保留在 messages 里,从不清理
  • 对策:ContextEditingInterceptor(裁剪旧工具结果)+ SummarizationHook(压缩历史)

上下文太少(信息断层)

  • 症状:用户说"改一下那个航班",Agent 问"请问是哪个航班?"——明明用户上一句刚说过
  • 原因:上下文被裁剪得太激进,关键信息被删掉了
  • 对策:精准控制裁剪策略,保留关键业务信息(如 ContextEditingInterceptor 的 excludeTools("searchKnowledge")

经验参考值(不是固定标准,根据业务场景调整):

  • 系统提示:专注角色定义和执行规则,控制在 500-1000 token;超过这个范围通常意味着把动态内容误放进了静态提示
  • 对话历史:保留最近 5-10 轮就够了,更早的对话让 SummarizationHook 压缩成摘要
  • 工具结果:最近 2-3 条,知识库检索结果不裁剪(excludeTools("searchKnowledge")
  • 动态注入:只注入当次请求真正需要的字段,不要把整个用户画像都塞进来

第三种失败:上下文污染(Context Poisoning)

工具返回了噪声信息(查到了不存在的航班、知识库命中了错误政策),这条数据进了 messages 就成为后续所有轮次的推理基础。症状:Agent 开始基于错误数据做推荐,即使后续工具返回了正确数据,也难以彻底覆盖——早期出现的信息有更强的"锚定效应"。

对策三件套:工具函数做防御性校验(返回结构化 error 而不是噪声文本)+ ContextEditingInterceptor 清除低质量工具结果 + 关键工具的结果做幂等性校验(同一个查询不重复追加)。

第四种失败:位置效应被忽视

"Lost in the middle" 在 Agent 场景里有直接的工程含义:关键规则被埋在 systemPrompt 中间段、被大量工具结果淹没,等价于没有放。

工程含义:把最重要的执行约束放在 systemPrompt 开头;最新的用户意图放在 messages 末尾(最近的信息自然权重更高);用 SummarizationHook 把历史关键信息提炼到摘要里,而不是让它沉在 messages 链条中间。

工程判断:上下文管理的早期事故往往从"太多"开始,但最难排查的事故是"污染"——因为症状(回答越来越奇怪)和原因(某条工具结果带进了错误数据)之间隔了好几轮,很难对应上。在 Agent 上线前,建议专门测试"工具返回异常数据"的场景,验证系统的降级行为。


九、五种上下文技术的边界

技术上下文类型瞬态/持久用途代价
systemPrompt.formatted()模型上下文持久(构建期固定)注入启动时就能确定的内容(当前日期、角色定义)不能按用户/请求变化
RunnableConfig.metadataToolContext工具上下文瞬态(工具层可见)影响工具行为:差异化数据、差异化查询范围LLM 看不到,只有工具能用
ToolContext.extraState 回写工具上下文持久(写入 OverAllState)工具执行结果直接写回状态,后续 Hook/工具读取需要在 getKeyStrategys() 中声明对应 key
ModelInterceptor 修改请求模型上下文瞬态(单次调用可见)本次调用需要 LLM 知道某信息,但不持久化需要实现 ModelInterceptor,不影响 OverAllState
ModelHook.beforeModel() 追加 messages生命周期上下文持久(写入 OverAllState)需要 LLM 永久知道某信息(对话级上下文)AppendStrategy 自动堆积,必须配合 ContextEditingInterceptor 清理

选型决策树

需要影响 LLM 行为吗?
├── 不需要(工具行为就够了)→ metadata → ToolContext(路径 A)
└── 需要(LLM 要"知道")
    ├── 这次请求之后还需要吗?
    │   ├── 不需要(一次性提示)→ ModelInterceptor(路径 B,推荐)
    │   └── 需要(持久对话上下文)→ Hook beforeModel(路径 C,慎用)
    └── 是否是构建期就能确定的?
        └── 是 → systemPrompt.formatted()

工程判断:路径 B(ModelInterceptor)是生产环境里最常被低估的选项。大多数"让 LLM 知道当前用户身份"的需求,都是瞬态的——用 ModelInterceptor 比用 Hook 追加 messages 便宜得多,也安全得多,因为它不会向 OverAllState 写入任何东西。


十二、生产视角:上下文的系统性管理

Token 预算分配

上下文窗口是有限资源,需要主动分配,而不是"剩多少用多少"。以 8K token 的窗口为例:

区域预算内容
systemPrompt600–800角色定义 + 核心执行规则
对话历史2000–3000最近 8–10 轮 + 历史摘要
工具结果2000–2500最新 2–3 条工具调用结果
输出预留1200–2000必须提前留,否则生成被截断

最容易被忽视的是输出预留。如果 input tokens 占满窗口,模型输出会被强制截断——工具调用的 JSON 损坏、回答被切在句子中间、API 报 context length exceeded。这类问题在压力测试时才暴露,上线后才发现,是典型的"没有提前规划 Token 预算"的代价。

上下文三温存储

借用 CPU 缓存分层的思路管理上下文:

热(最近 N 轮完整对话)  → 直接进窗口,高保真,完整可读
温(N+1 至 M 轮摘要)    → SummarizationHook 压缩后进窗口,中保真
冷(更早的历史)          → MemorySaver 存档,按需检索(Agentic RAG),不默认进窗口

ticket-agent 当前实现了热层 + 温层(MemorySaver + SummarizationHook)。冷层适合对话超过 50 轮、或业务需要跨会话检索历史的场景,通常用向量数据库 + Agentic RAG 工具来实现。

工程判断:90% 的 Agent 场景热 + 温两层就够了。冷层带来的复杂度(向量索引维护、检索召回率、相关性阈值调优)在大多数业务里远大于收益。先上热+温,真的遇到问题再考虑冷层。

三个常见反模式

反模式 1:上帝 systemPrompt

把所有业务规则都塞进系统提示,认为越详细越安全。结果:2000+ token 的 systemPrompt,其中 80% 对当前对话无关,但每轮都消耗 token;模型对密集规则列表的遵从率也随长度下降——研究表明超过一定长度后,靠后出现的规则被违反的概率显著上升。

修复:systemPrompt 只放角色和核心工作方式,具体业务规则通过动态注入(路径 A 或 B)在需要时给出。

反模式 2:工具结果囤积

每次工具调用的完整结果都追加到 messages,不做任何清理。10 次工具调用后,上下文里有 10 份航班列表、10 份政策文本,Token 是第 1 轮的 10 倍,但绝大多数内容对最终决策没有价值,反而制造噪声。

修复:ContextEditingInterceptor 清理旧工具结果,只保留最新 2–3 条;知识库检索结果不裁剪(excludeTools("searchKnowledge"));或用摘要替代完整工具输出。

反模式 3:无驱逐策略

系统里没有任何上下文清理机制(没有 SummarizationHook,没有 Interceptor),靠 LLM 自己"记住最重要的事"。结果是性能随对话轮次线性下降,Token 费用失控,最终某天某个长对话触发 context length exceeded 并把异常堆栈暴露给用户。

修复方法只有一个:在系统设计阶段就规划清理策略,上线后补救的成本是前期设计的 10 倍。


十三、架构演进视角

从第 10 章的"可控运行时"到第 11 章的"上下文可编程",每一轮推理看到的上下文终于可以主动设计:

在这里插入图片描述

本章的终点:增加 2 处新组件、2 处增强。每个标注 ● NEW● ENHANCED 的位置,都对应解决了一个"上下文怎么进来"的工程问题:

新增 / 增强上下文类型解决的问题
AgentController.addMetadata()运行时上下文请求参数到 Agent 内部的唯一入口,不污染 messages
ContextEnrichmentHook生命周期上下文(持久)请求级上下文写入 OverAllState,跨轮可读、跨节点共享
AgentToolNode(ToolContext 增强)工具上下文工具行为按 userTier 差异化,LLM 不感知
MemorySaver(持久化扩展)生命周期上下文(持久)userTier / timeOfDay 跟随 State 跨请求保存

加了这 4 处变化之后,addMetadataContextEnrichmentHookOverAllStateUserTierLimitHook + 工具层这条链路全部打通。如果你还需要 LLM 在推理时感知用户等级(改变语气、主动推荐),可以继续在 ModelInterceptor 里叠加——不影响 OverAllState,不增加 Token 开销。

这不是额外加了几个类——而是让 Agent 从"不知道谁在问"变成"每轮推理都拿到正确上下文"的关键一步。


十四、分布式场景下的上下文一致性

单机运行时,ContextEnrichmentHookRunnableConfig.metadata 读取 userTier,写入 OverAllStateMemorySaver 把 State 保存在 JVM 堆内存。这一切在单台服务上运行正常。

两台服务同时运行时,问题就来了:用户的第 1 轮请求被实例 A 处理,第 2 轮被负载均衡分配到了实例 B。如果用的是 MemorySaver(内存实现),实例 B 找不到实例 A 写入的 OverAllStateuserTier 归零,用户从 VIP 降级成普通用户。

解决路径唯一:切换到 RedisCheckpointSaver(第 13 章详细讲)。但 Context Engineering 这一层还有一个额外的问题:

// Controller 里:从请求参数读 userTier(安全可控)
RunnableConfig config = RunnableConfig.builder()
    .threadId(request.getThreadId())
    .addMetadata("userTier", request.getUserTier())  // 来自 JWT 解析,生产环境应在 Gateway 验证
    .build();

问题:如果 userTier 从客户端请求参数来,用户可以自己构造 userTier=vip 绕过权限。

生产环境的正确链路:

HTTP 请求
  → API Gateway(验证 JWT,解析用户身份)
  → 后端 Controller(从 SecurityContext 或请求头的受信 HeaderuserTier)
  → RunnableConfig.addMetadata("userTier", securityContext.getUserTier())

JWT 验证和 userTier 解析在 Gateway 完成,后端不信任客户端传入的 userTier。这个链路关系到 UserTierLimitHook 的限流是否真的生效——如果 userTier 可以伪造,差异化限流形同虚设。

Token 预算的量化参考(生产经验):

上下文类型典型 Token 量建议预算比例
系统提示(角色 + 规则)500–12008–15%
动态注入(userTier + 时间 + 用户画像)200–6003–8%
对话历史(经摘要压缩后)1000–300020–40%
工具结果(经裁剪后保留最新几条)500–200010–25%
输出预留1000–200015–25%

ModelCallLimitHook + SummarizationHook 守住"对话历史"这一层(第 10 章已有实现),是 Token 费用最容易失控的地方。


十五、评论区聊聊

选型问题:你在项目里遇到过"用户信息怎么传给 Agent"的问题吗?是放进 systemPrompt、还是每次请求带参数、还是别的方案?踩过什么坑?

踩坑问题:上下文注入最容易犯的错误之一是把动态信息塞进 systemPrompt——构建期写死、运行期全员共享。你有没有排查过"为什么所有用户拿到同一份回答"这类问题?

前瞻问题:本章的 userTier 是手动从请求参数传入的,生产环境应该从 JWT/Session 解析。如果 Agent 需要主动去数据库拉用户画像(而不是请求方带进来),你觉得这个"上下文预加载"的逻辑应该放在哪里——请求入口、Hook、还是单独的工具?

评论区见。


本文代码仓库:[GitHub 链接](完成项目后补充)

系列目录:[Spring AI Alibaba Agent 实战系列]

上一篇:[(十)ReactAgent 运行时:State、Hooks 与 Interceptors 深度解析]


如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流。