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

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 每一轮推理的上下文并不是静止的------它在随着执行进程动态变化:

objectivec 复制代码
第 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 用户看到更多航班信息、更低的舱位锁定费等。

java 复制代码
// 工具函数:读取 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 正是为这个场景设计的。

java 复制代码
// 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

java 复制代码
// 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 更安全的选择。


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

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

核心链路是:

css 复制代码
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

java 复制代码
@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 注入用户等级

java 复制代码
@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 链更新

java 复制代码
// 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:

java 复制代码
// 工具函数:读取上下文,并把结果写回 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 包含:

markdown 复制代码
你是机票客服型智能体「票小蜜」。

执行规则:
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 清理

选型决策树

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

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


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

Token 预算分配

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

区域 预算 内容
systemPrompt 600--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 这一层还有一个额外的问题:

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

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

生产环境的正确链路:

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

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

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

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

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


十五、评论区聊聊

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

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

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

评论区见。


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

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

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


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


相关推荐
用户2018792831672 小时前
故事:小白的“无限循环”噩梦与大师的 /loop 魔法
人工智能
用户2018792831672 小时前
/branch 你点了一份代码,Cli 帮你分成了两碗
人工智能
Nyarlathotep01132 小时前
并行设计模式(3):Future模式
java·后端
kishu_iOS&AI2 小时前
机器学习 —— 线性回归
人工智能·机器学习·线性回归
阿里云大数据AI技术2 小时前
OpenClaw 长记忆增强:基于 Hologres + Mem0 的企业级方案
人工智能
北京耐用通信2 小时前
工业自动化领域耐中达讯自动化CC-Link IE转EtherCAT技术解决方案
人工智能·物联网·网络协议·自动化·信息与通信
秋风不问归客2 小时前
Springboot面试全面整理
spring boot·后端·面试
飞哥数智坊2 小时前
【大纲】TRAE AI 编程入门扩展课:一些可能有用的编程常识
人工智能·ai编程·trae
恋猫de小郭2 小时前
Google 开源大模型 Gemma4 怎么选,本地跑的话需要什么条件?
前端·人工智能·ai编程