文章总结: 本文分享复杂自主Agent系统优化LLM前缀缓存的工程实践。针对上下文非线性累积致命中率仅1%的痛点,作者分三步解决:用哈希稳定派生Nonce替代随机Nonce以消除字节抖动;按稳定性将Prompt划分为高静态、半动态与动态段以隔离易变内容;对累积记忆采用时间分桶机制,以统一冻结时间点对齐各类记忆层边界。最终命中率升至60%,Token成本下降超50%,为AI系统缓存优化提供了可复用的分层与冻结架构方案。 综合评分: 90 文章分类: AI安全,安全开发,安全工具
Memfit AI 安全性和缓存优化工程实践
原创
YAK YAK
Yak Project
2026年7月17日 17:35 湖南
在小说阅读器读本章
去阅读
一、从聊天应用到编程 Agent:上下文是怎么累积的
先从大家最熟悉的场景说起。
最基础的形式是”累积对话”——ChatGPT 网页版、各种基于 API 搭的客服 Bot、写作助手,底层 Prompt 的组织方式都是同一套范式:你发一条消息,AI 回一条,下一轮调用时把历史消息原样追加进 messages 数组发给模型。在这种模式下,Prompt 缓存优化几乎是自动的:system prompt 固定不变,历史消息天然构成稳定前缀,上游 LLM 的前缀缓存沿着对话历史一路命中,越聊越省钱。
再进一步是”工程 Agent”——比如 Codex CLI、Claude Code 这类工具。它们的上下文累积方式仍然是消息追加,但追加的不再只是用户输入和 AI 回复,还包括工具调用结果:Agent 读了一个文件,文件内容作为一条 assistant/tool message 追加进去;Agent 跑了一行命令,命令输出也追加进去;Agent 编辑了代码,diff 结果同样追加。本质上,它们把”工作过程”当作”对话历史”来累积——每一步操作的输入和输出都变成 messages 数组里的一条记录,下一轮 LLM 调用时把整段历史发给模型。
这种方式对 Prompt 缓存其实比较友好:system prompt(含工具定义)不变,历史消息按顺序追加,前缀天然稳定。每新增一条消息只是在前缀末尾追加内容,上游前缀缓存可以一路命中到上一次的最后一条消息。唯一的代价是上下文会越来越长,当超过模型窗口时需要做截断或摘要,但那是另一个问题。
我们的系统面临的情况又不一样。
我们的系统是一个多能力、带累积记忆、跑 DAG 任务图的自主 Agent 系统。它和前两者的关键区别在于:上下文不是沿一条线性对话流追加的,而是在不同任务上下文、不同子循环之间跳跃式拼装的。单次任务会发起几十到上百轮 LLM 调用,每轮调用的 Prompt 内容取决于当前执行到哪个任务、跑了哪个子循环、加载了哪些能力、Timeline 积累了多少——而不是简单地把”上一轮的对话”拼在前面。
具体来说,一次 LLM 调用的 Prompt 里混合着这些成分:
- Agent 特质与系统角色指令:性格设定、输出格式硬规则、推理方法论、不同系统角色的核心指令。这些内容在理想情况下应该永远不变,但最初它们和 caller-specific 的内容(比如不同子循环的 OutputExample、能力开关)混在一起,没有明确的边界。
- 能力清单:工具、AI 蓝图(Forge)、技能(Skill)、MCP Server 等。系统有仅内置就有数十种能力,可通过
search_capabilities动态发现和按需加载。每次系统或用户加载新的能力,Prompt 中的能力清单就会变化。 - 累积记忆:这是最棘手的部分。系统有四层记忆在 Prompt 里实时渲染——Timeline 时间线(每一步操作和观察结果的追加记录,一次任务可能积累几百到几千条)、Session Evidence(会话级证据,历轮工具执行后被显式写入的”已确认观测”)、Persistent Memory(跨会话的持久记忆)、Midterm Memory(Timeline 过长时自动压缩归档的旧片段)。这些记忆每轮都在增长。
- 多任务上下文:在 Plan-Execute 模式下,用户的一个目标被拆成树状任务,按依赖关系拓扑执行。每个叶子任务带着父任务链、当前任务目标、执行指令跑一轮独立的 ReAct 循环。不同子任务的上下文内容各不相同。
- 迭代反馈:ReAct 循环每轮的验证结果、下一步行动计划、工具调用返回值。这些内容每轮都不同。
- 当前时间、工作目录等环境信息:看起来微不足道,时间的自然增长,它会让整段 Prompt 的字节 hash 每次都变。
问题在于,这些成分最初没有明确的界限——它们被拼成一段连续的文本发给 LLM,稳定的和易变的混在一起。上游前缀缓存沿着 Prompt 从头开始做字节级最长公共前缀匹配,一旦遇到第一个变化的字节,后面的内容无论多么稳定,都无法命中。
于是,Agent 每跑一步,Timeline 增长一条,能力清单可能变了一项,迭代反馈完全换了一轮——前缀缓存在很早就被击穿,后面几千 token 的稳定内容全部按全价计费。
最初的实测数据印证了这一点:一次 hostscan 任务跑下来,50~130 次 LLM 调用,缓存命中率只有 1.10%。99% 的 token 在按全价计费,而其中大部分本应是稳定内容。
这就是我们要解决的问题:在一个成分复杂、边界模糊、记忆持续增长的系统里,如何让上游 LLM 的字节级前缀缓存尽可能命中
二、问题一:缓存破坏的”单体成分”
在系统最初的状态里,Prompt 的各种成分没有明确界限,稳定的和易变的混在一起。但要拆解这个问题,首先要识别出哪些成分是”即使内容不变也会破坏缓存”的——它们是嵌入在 Prompt 字节里的定时炸弹。
YAK
2.1 随机 Nonce:防注入的代价
我们的系统大量使用 AITag(AI 标签)来隔离 Prompt 中的结构化内容,格式形如:
```
<|PARENT_TASK_a3x9k2|>... 父任务上下文 ...<|PARENT_TASK_END_a3x9k2|>
其中 `a3x9k2` 是 nonce。Nonce 的存在有明确的安全意义:它防止 Prompt Injection 攻击——如果攻击者构造的输入恰好包含 `<|PARENT_TASK_xxx|>` 这样的标签,就能伪造任务上下文。有了随机 nonce,攻击者无法猜测当前轮的标签字面量,注入的标签会被当成普通文本忽略。
问题出在 nonce 的生成方式:`utils.RandStringBytes(6)`,**每次 LLM 调用都随机生成一个新的**。
这意味着,即使两次调用的 Prompt body 完全相同——父任务链一样、当前任务目标一样、执行指令一样——但因为 nonce 变化了,整段文本的字节序列就不同了。上游前缀缓存沿着 Prompt 做字节级匹配,遇到 nonce 变化的位置就断了,后面所有内容全部 miss。
类似的问题还出现在其他"看起来无害"的单体成分上:
* **`time.Now().String()`**:时间戳,每秒都不同,让整段 Prompt 的字节 hash 每次都变;
* **工具调用缓存块里的 turn nonce**:每轮 LLM 调用的 nonce 直接嵌在 `<|CACHE_TOOL_CALL_<nonce>|>` 标签里,跨轮必然不同。
这些成分的共同特点是:**它们的相关有效内容本身可能不变,但嵌入的标识符每次都随机,导致字节序列不稳定**。
***YAK***
**2.2 稳定派生:让安全与缓存共存**
解决思路不是去掉 nonce(安全不能妥协),而是让 nonce 在"内容不变时也不变"。
我们设计了 `StablePromptNonce` 函数,用哈希把语义标识符映射成稳定的 6 字符 nonce:
func StablePromptNonce(parts …string) string { h := fnv.New64a() for i, p := range parts { if i > 0 { h.Write([]byte{0}) } h.Write([]byte(p)) } v := h.Sum64() // 映射到 6 字符稳定 nonce …}

同一组 part **反复调用必返回相同 nonce**。
对于时间戳,解决方案更直接:把 `time.Now()` 从所有可能进入缓存前缀的段落中移除,时间信息只放在最易变的尾段。
对于工具调用缓存块,nonce 改用占位符字面量 `"[current-nonce]"` 渲染——LLM 看到方括号占位符理解为"应替换为当前轮 nonce",解析端通过双注册(turn nonce + 占位符字面量)兜底,任一种 LLM 行为都能正确解析。
这一步的效果是:缓存命中率从 **1.10% 提升到 6.27%**。绝对值仍然不高,但这是从"几乎完全 miss"到"开始能命中"的质变——单体成分不再无差别地击穿前缀。
**三、问题二:如何把混乱的成分归入正确的段**
解决了单体成分的字节抖动之后,面对的是更大的问题:Prompt 的各种成分仍然混在一起,没有边界。上游缓存从 Prompt 开头开始做字节级最长公共前缀匹配,一旦遇到第一个变化的字节,后面再稳定的内容也无法命中。
解法是**按稳定性分层**——把成分按"跨调用不变的"和"每轮都变的"分开,让稳定的在前,易变的在后,在两者之间插入显式的缓存边界。
***YAK***
**3.1 High-Static 段:把"永远不变"的收整到一起**
第一类要收整的是**跨所有调用永远不变的内容**:**Agent 性格特质、输出格式硬规则、AITAG 协议、推理方法论**。这些内容无论当前跑的是哪个子循环、执行的是哪个任务,都不应该变化。
最初的问题是:这段内容里混进了**系统角色差异化**的字段。不同系统角色注入的 OutputExample 不同,能力配置在也会变。这些字段虽然每次调用**自身稳定,但跨角色切换时就会变**,把它们放在 high-static 里,任何 系统角色切换都会让整段内容漂移。
修复的第一步是**把 caller-specific 字段全部迁出**:OutputExample、能力开关、Schema 等 caller 维度才稳定的内容,下沉到后面的 semi-dynamic 段。high-static 段只保留真正跨所有 caller 都不变的内容。
修复的第二步是**提炼系统共享约束补足 token 数量**。主流模型厂商对显式缓存简历有最小窗口约束,最初的 high-static 段只有少量的内容,后续我们追加了 AITAG Protocol、Reasoning Protocol、Experiment Method Protocol 等跨所有 caller 都不变的方法论内容。

***YAK***
**3.2 Semi-Dynamic 段与 Dynamic 段:从混乱的角色 Prompt 中整合出标准结构**
在 high-static 之外,Prompt 中还有大量"同一 caller 内稳定但跨 caller 会变"的内容。最初这些内容和"每轮都变"的内容混在一起,没有边界。
以 ReAct 主循环为例,最初的 Prompt 里,Schema(当前可用 action 的 JSON Schema 定义)、OutputExample(输出格式示例)、Instruction(角色指令)和用户最新输入、迭代反馈数据、注入记忆等全部拼在一段连续文本里。Schema 在同一 loop 内每轮都一样,但和每轮都变的 ReactiveData 混排,导致缓存从第一个变化的字节处就断了。
整理后的方案是把这些内容分成两段:
**semi-dynamic 段**(同一 caller 内稳定):
* Instruction(当前系统的角色核心指令):同一 loop 内不变,但是切换角色会变化
* Schema(当前 action 的 JSON Schema):同一 loop 内不变
* OutputExample(输出格式示例):同一 caller 内不变
* CacheToolCall(工具调用缓存块):用占位符 nonce 渲染后跨轮字节稳定
**dynamic 段**(每轮都变):
* UserQuery(用户最新输入):每轮不同
* ReactiveData(迭代反馈、验证结果、工具返回值):每轮不同
* InjectedMemory(注入的记忆检索结果):每轮不同
* ExtraCapabilities(动态发现的能力):每轮可能不同
这一步的效果是:ReAct 主循环 5 次连续 chat 实现了 4 段全部 byte-identical,`prefix_hit_ratio = 100%`。全量命中率提升到 **~25-30%**。
**四、问题三:累积记忆的滑动窗口困境**
前两个问题解决了"静态成分"和"成分边界"的问题,但系统中最大的不稳定因素还没处理:**累积记忆**。
***YAK***
**4.1 困境:Timeline 每步都在增长**
我们的系统有四层记忆在 Prompt 里实时渲染。其中 Timeline 时间线是最核心的:它记录了 Agent 每一步的操作和观察结果,一次任务可能积累几百到几千条记录。每跑一步,Timeline 就追加一条新记录——这意味着如果 Timeline 直接塞在 Prompt 里,前缀每轮都在变,缓存必 miss。
Session Evidence(会话级证据)也有类似的特性:历轮工具执行后被显式写入的"已确认观测",会随着任务推进不断累积。
这些记忆不可能简单丢弃——它们是 Agent 理解"已经做了什么"的事实依据。但也不能让它们无节制地击穿缓存。
***YAK***
**4.2 解法:Timeline 分桶 + Frozen/Open 边界**
关键观察是:**Timeline 的旧条目一旦写入就不再变化,只有最新写入的条目还在增长**。
我们利用 `GroupByMinutes` 机制把 Timeline 按绝对时间分桶(默认 3 分钟一个桶):
| | | |
| --- | --- | --- |
| 桶类型 | 含义 | 字节稳定性 |
| Reducer block | 旧条目的压缩摘要 | 永久字节稳定 |
| Frozen interval block | 已封闭的时间桶(不再追加) | 永久字节稳定 |
| **末尾 Open interval block** | 当前还在写入的桶 | 会变 |

这样,每次调用时 frozen 段的字节是稳定的(旧条目不变),只有 open 段在变——缓存边界精确地落在"最后一个还在变化的桶"之前。
***YAK***
**4.3 依托 Timeline 的冻结时间线,统一其他记忆的冻结标准**
Timeline 分桶解决了 Timeline 自身的缓存问题,但系统里还有其他累积记忆——Session Evidence(会话级证据)和 Session Artifacts(工作区产物)——它们同样在每轮增长,同样会击穿缓存。如果每种记忆各自维护一套冻结逻辑,不仅工程复杂,而且各自冻结的时机不同,frozen 段的边界会对不齐,反而互相破坏。
我们的做法是:**以 Timeline 的冻结时间点作为全局统一基准,让所有记忆内容蹭同一个时间点冻结**。
具体来说,我们设计了冻结相关内容有一个统一的入口。它先渲染 Timeline 的 frozen/open 分桶,从中提取出一个 `FrozenTimeUnix`——即"最后一个已封闭桶的时间戳上限"。然后把这个时间戳传给 Session Evidence 和 Session Artifacts 的渲染函数:
func BuildPromptFrozenOpenMaterials(config *Config, openNonce ...string) PromptFrozenOpenMaterials { // 1. Timeline 先分桶,拿到冻结时间点 timelineBlocks := RenderTimelineFrozenOpen(config.GetTimeline()) // 2. 用同一个冻结时间点渲染 Artifacts artifactBlocks := RenderSessionArtifactsFrozenOpen(config, timelineBlocks.FrozenTimeUnix) // 3. 用同一个冻结时间点渲染 Evidence evidenceBlocks := config.GetSessionPromptState(). GetSessionEvidenceFrozenOpenBlocks(timelineBlocks.FrozenTimeUnix, nonce) return PromptFrozenOpenMaterials{ TimelineFrozen: timelineBlocks.Frozen, TimelineOpen: timelineBlocks.Open, TimelineFrozenTimeUnix: timelineBlocks.FrozenTimeUnix, SessionArtifactsFrozen: artifactBlocks.Frozen, SessionArtifactsOpen: artifactBlocks.Open, SessionEvidenceFrozen: evidenceBlocks.Frozen, SessionEvidenceOpen: evidenceBlocks.Open, ... }}
“`
这样做的好处是:当 Timeline 的一个时间桶从未封闭变成已封闭(从 open 变成 frozen)时,FrozenTimeUnix 向前推进,Session Evidence 和 Session Artifacts 也同步把此时点之前的内容冻结。三类记忆的 frozen/open 边界在同一轮调用中完全对齐,frozen 段作为一个整体进入缓存前缀,不会出现”Evidence 冻结了但 Timeline 还没冻结”的错位。
这一步的效果是:全量命中率提升到 ~60%。
总结
整个过程解决了三个层面的问题,命中率从 1% 一路提升到 60%:
| | | | | | — | — | — | — | | 阶段 | 解决的问题 | 关键动作 | 命中率 | | 修复单体成分 | Nonce 随机、时间戳抖动等”内容不变但字节变”的隐患 | 稳定派生 Nonce、移除时间戳 | 1% → 6% | | 分段分层 | 稳定内容和易变内容混排,前缀被第一个变化字节击穿 | high-static 收整(迁出 caller-specific 字段 + 补足 1500 token)、semi/dynamic 拆分、Hijacker 按边界切割并打 cache_control | 6% → ~25% | | 累积记忆冻结 | Timeline / Evidence / Artifacts 每轮增长,前缀永远在变 | Timeline 分桶 + Frozen/Open 边界、以 FrozenTimeUnix 统一三类记忆的冻结时机、Frozen Partition 冻结 plan 产物 | ~25% → ~60% |
最终效果:命中 token 按 input 单价的 10% 计费,60% 命中率意味着token 成本下降约 50-60%。
END
更新记录
Yakit 1.4.8-0717
-
修复 HTTPFlow 表格加载慢的问题
-
修复 WebFuzzer hex 渲染值不一致问题
-
AI Agent 工具卡片展示和布局优化,失败卡片样式优化
-
任务/子 Agent 任务定位高亮复用化与样式优化
-
插件 Tab 新增历史记录,插件执行结果优化
-
YakitEditor 右键菜单支持打开/关闭二进制组件
-
OpenAPI 文档解析新增进度显示与取消功能
-
修复 IM 机器人删除按钮失效
-
连接页面支持翻译,导航栏导入资源文案改为导入插件/流量
-
字典/代理/热加载管理合并为资源管理
-
Agent 增加对话模式选择,新增 Goal 和 Multi-Agent
-
Yak Runner 右侧 AI 改代码优化,增加片段更改
Memfit v11.0.3-0717
-
AI Agent 工具卡片展示和布局优化,失败卡片样式优化
-
任务/子Agent 任务定位高亮复用化与样式优化
-
优化底层数据逻辑
-
Agent 增加对话模式选择,新增 Goal 和 Multi-Agent
Yaklang v 1.4.8-beta5
-
新增 MCP 工具集(chaos maker、fingerprint、http builder 等)
-
修复大型项目 SyntaxFlow 扫描的内存占用与卡死问题
-
新增代码审计子智能体支持,增强 AI ReAct 循环与工具调用能力
-
新增 AI 工具名称双语支持
-
增强 WebSocket 错误处理与转发能力
-
修复 CVE 查询响应分页为空的问题
-
新增 SyntaxFlow 规则
YAK官方资源
Yak 语言官方教程:
https://yaklang.com/docs/intro/
Yakit 视频教程:
https://space.bilibili.com/437503777
Github下载地址:
https://github.com/yaklang/yakit
Yakit官网下载地址:
https://yaklang.com/
Yakit安装文档:
https://yaklang.com/products/download_and_install
Yakit使用文档:
https://yaklang.com/products/intro/
常见问题速查:
https://yaklang.com/products/FAQ
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:Yak Project YAK YAK《Memfit AI 安全性和缓存优化工程实践》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论