gogo-agent

上下文工程——优化系统提示词利用KV缓存

我们在前面讲Manus的上下文工程实践的时候,讲过KV Cache ✅实战:Manus的上下文工程实践 Manus 曾经发表过一篇文章《Context Engineering for AI Agents: Lessons from …

TL;DR

我们在前面讲Manus的上下文工程实践的时候,讲过KV Cache ✅实战:Manus的上下文工程实践 Manus 曾经发表过一篇文章《Context Engineering for AI Agents: Lessons from …

我们在前面讲Manus的上下文工程实践的时候,讲过KV Cache

✅实战:Manus的上下文工程实践

Manus 曾经发表过一篇文章《Context Engineering for AI Agents: Lessons from Building Manus》,里面介绍了一下他们在上下文工程方面的实践。 这篇文章我反反复复看过好几次,最开始 LLMentor 大模型是自回归的:生成第 N 个 token 时,要「看到」前面所有 token。Transformer 的注意力机制里,每个历史 token 都会被算成一对 Key / Value 向量(简称 KV)。这些 KV 只跟「这个 token 及它前面的内容」有关,跟后面还没生成的内容无关——这是关键性质。 于是有了 KV 缓存:把已经算过的 token 的 KV 存起来,下次请求如果开头一模一样,就直接复用这段缓存,跳过重复计算。因为复用的必须是「从第一个 token 开始、连续一致的一段开头」,所以也叫前缀缓存(Prefix Cache)。 它有一个铁律:前缀必须逐字节一致,且一旦中间某个 token 变了,从那个位置往后的所有缓存全部失效。因为后面 token 的 KV 依赖前面的内容,前面一变,后面就得重算。 百炼(DashScope)提供隐式缓存(Context Cache):你不需要显式声明缓存哪一段,框架/平台会自动识别请求之间相同的前缀并复用。命中前缀的那部分 token,按大约 10% 计费 对话类应用里,system prompt 往往是最长、最稳定的一块(gogo-agent 的子 Agent 系统提示词都有几千 token,含角色定义、时间规则、双通道路由、输出规范等)。如果每轮对话这段都能命中缓存,省下的就是「几千 token × 每轮 × 每个用户」的真金白银,还顺带降低首 token 延迟。 所以「上下文工程」在这里的核心命题就是一句话:想尽办法让长长的 system prompt 前缀在多轮之间逐字节稳定,从而稳定命中隐式缓存。 而最大的敌人,就是「藏在 system prompt 里的动态内容」。 设想一个最自然的写法:把当前时间直接模板进系统提示词。

你是差旅助手…… 当前日期:2026-07-27,当前时间:14:32:07 (后面还有几千 token 的规则)

问题来了:当前时间:14:32:07 这一行每秒都在变。它位于 system prompt 内部、靠近开头。按前缀缓存的铁律,这一行一变,它后面那几千 token 规则的缓存全部作废——每轮对话都得从这行往后全量重算,隐式缓存基本永远命中不了开头以外的东西。 这就是「藏在前缀里的动态内容毒化整个前缀」。gogo-agent 的 PromptLoader.load()(旧方法)就是这种写法,注释也明说了它「兼容旧用法,不需要缓存优化的场景仍可使用」:

public static String load(String filename) {
    // ...
    return content
            .replace("{{current_date}}", today.format(DATE_FORMATTER))
            .replace("{{current_weekday}}", weekday)
            .replace("{{current_time}}", LocalTime.now().format(TIME_FORMATTER)); // ← 秒级变化,毒化前缀
}

但 Agent 又确实需要知道当前时间(用户说「明天」「3 天后」,模型得算成具体日期)。矛盾就在这里:时间信息必须有,但它一旦进了 system prompt 前缀就会破坏缓存。gogo-agent 的解法非常干净——把这两件事拆开。

静态前缀 + 独立的时间消息

思路一句话概括:system prompt 里只放「一天内不变」的东西,把「秒级变化」的当前时间抽出来,作为一条独立的 system 消息插在前缀之后。这样前缀在一整天内逐字节稳定,缓存命中;时间信息依然送达模型,只是它待在「前缀之外」,变了也只影响它自己,不牵连前面的缓存。

静态加载:PromptLoader.loadStatic()

新方法把 {{current_time}} 占位符替换成一个固定字面量,而不是真实时间:

/**
 * 静态加载 prompt 模板:仅注入 current_date 和 current_weekday(每日变化一次),
 * 不注入 current_time,以保持 system prompt 前缀在同一天内完全稳定,
 * 利于百炼隐式缓存命中(相同前缀按 20% 计费)。
 * 动态时间由 DynamicTimeInjectionHook 作为独立 system message 注入。
 */
public static String loadStatic(String filename) {
    String content = loadResource("prompts/" + filename);
    // ...
    LocalDate today = LocalDate.now();
    String weekday = today.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINA);
    return content
            .replace("{{current_date}}", today.format(DATE_FORMATTER))     // 一天变一次
            .replace("{{current_weekday}}", weekday)                       // 一天变一次
            .replace("{{current_time}}", "(见下方动态注入)");             // ← 固定字面量!不是真实时间
}

关键就在最后一行:{{current_time}} 被替换成恒定的字符串 (见下方动态注入)。于是无论这一秒是 14:32 还是 14:33,loadStatic 产出的这段文本完全一样。日期和星期虽然也模板进去了,但它们一天只变一次——前缀因此在「同一自然日内」逐字节稳定(跨零点会变,这是设计上的已知取舍,注释也承认「同一天内完全稳定」)。 对应的系统提示词模板里就是这么写的(以 InfoAgent 为例):

当前日期:{{current_date}}({{current_weekday}})。以注入的"星期几"为准,
不要自行从日期推算星期。当前时间见本消息列表中的动态时间注入。

注意「当前时间见本消息列表中的动态时间注入」这句——它在提示词里明确告诉模型:真正的当前时间不在这段里,去后面的消息里找。

时间独立注入:DynamicTimeInjectionHook

真实时间由这个 Hook 在每轮推理前,作为第二条 system 消息插入。全文如下:

@Component
public class DynamicTimeInjectionHook implements Hook {

    @Override
    public int priority() {
        return 1;  // 最先执行,在 AutoContextHook(默认优先级)之前
    }

    @Override
    public <T extends HookEvent> Mono<T> onEvent(T event) {
        if (event instanceof PreReasoningEvent preReasoning) {
            return handle(preReasoning).map(e -> (T) e);
        }
        return Mono.just(event);
    }

    private Mono<PreReasoningEvent> handle(PreReasoningEvent event) {
        List<Msg> messages = event.getInputMessages();
        if (messages == null || messages.isEmpty()) {
            return Mono.just(event);
        }

        // 仅当第一条消息是 SYSTEM 角色时才注入(守卫,否则跳过)
        Msg firstMsg = messages.get(0);
        if (firstMsg.getRole() != MsgRole.SYSTEM) {
            return Mono.just(event);
        }

        // 构建动态时间 system message:"当前时间:HH:mm:ss"
        String timeContext = PromptLoader.buildTimeContext();
        Msg timeMsg = Msg.builder()
                .role(MsgRole.SYSTEM)
                .content(TextBlock.builder().text(timeContext).build())
                .build();

        // 在第一条 system message 之后(index=1)插入
        List<Msg> newMessages = new ArrayList<>(messages.size() + 1);
        newMessages.add(messages.get(0));   // [0] 静态 system prompt(前缀,逐字节稳定)
        newMessages.add(timeMsg);           // [1] 动态时间(前缀之外,可自由变化)
        for (int i = 1; i < messages.size(); i++) {
            newMessages.add(messages.get(i));  // [2..] 原有对话/工具消息
        }

        event.setInputMessages(newMessages);
        return Mono.just(event);
    }
}

三个设计细节值得品: 1. 插在 index=1:紧跟静态 system prompt 之后。消息序列因此固定为 [0]静态前缀 → [1]动态时间 → [2..]对话。message[0] 永远不被时间字符串挤动,缓存的前缀边界稳稳落在 message[0] 末尾。 2. priority()=1 最先执行:它必须在 AutoContextHook(负责压缩历史)等其他 Hook 之前跑,保证时间信息在后续处理前就已就位,也保证它对消息列表的改动是「最靠前、最确定」的。 3. 只有 message[0] 是 SYSTEM 时才注入:一个守卫。如果首条不是系统消息(异常情况),就跳过,避免把时间插到奇怪的位置。 buildTimeContext() 本身很简单,就是 "当前时间:" + LocalTime.now()——它每秒都变,但因为待在前缀之外,变了只影响它这一条 token 的计算,前面几千 token 的 system prompt 缓存完好无损。

版本提示

模型、框架与接口会持续变化。涉及版本号、参数与生产配置时,请在实践前对照对应官方文档。

LLMentor系统化学习大模型应用工程

内容来自个人课程知识库备份,并经过结构化整理。技术版本持续演进,生产使用前请结合官方文档验证。