Manus上下文工程学习思考

构建一个真正高效、智能的 AI 代理,其挑战远超于编写一个好的初始提示(Prompt)。成功的关键在于对"上下文(Context)“的持续、精心的设计与管理。上下文是模型进行思考、推理和学习的唯一依据。 以下是 Manus 项目中总结出的六大上下文工程核心原则,它们共同构成了一套完整的设计哲学。 原文:https://manus.im/blog/Context-Engineering-for-AI-Agents-Lessons-from-Building-Manus 一、围绕 KV 缓存进行设计 (Design Around the KV-Cache) 核心思想:为了最大化效率和降低成本,上下文的构建应严格遵循"仅追加(Append-Only)“原则。 1. 技术概念: KV 缓存 (KV-Cache):在大语言模型(基于 Transformer 架构)中,当模型要生成下一个词(token)时,它需要回顾并理解之前已经处理过的所有词。这个过程通过一种叫做"自注意力(Self-Attention)“的机制完成。 为了进行自注意力计算,模型会为每个输入的词生成三个关键的数学向量:Query (Q), Key (K), 和 Value (V)。 Key (K) 和 Value (V):可以理解为对过去每个词的"编码表示”。一个词的 K 和 V 向量一旦计算出来,只要它在上下文中的位置和内容不变,它就永远是固定的。 KV 缓存:就是一种存储机制,它把模型已经计算过的所有词的 K 和 V 向量保存下来。它的核心目的就是避免重复计算。当模型需要处理更长的文本时,可以直接从这个"缓存"中读取已经算好的 K 和 V 向量,而无需从头再来。 上下文 (Context):指的是在某一个时间点,输入给大语言模型的完整文本序列。对于一个 AI 代理(Agent)来说,这个上下文通常由一系列指令、动作和观察结果组成。 缓存命中 (Cache Hit) 和缓存未命中 (Cache Miss): 缓存命中 (Cache Hit):当模型处理一个新的、更长的上下文时,如果其开头部分与上一步的上下文完全相同,那么这部分相同的文本对应的 K 和 V 向量就可以直接从 KV 缓存中读取,无需重新计算。这就叫"缓存命中”。 缓存未命中 (Cache Miss):对于上下文中新的、之前没有处理过的部分,或者因为上下文结构改变而导致无法使用缓存的部分,模型必须对它们进行完整的计算来生成 K 和 V 向量。这就叫"缓存未命中”。 2. 问题背景: 大语言模型的推理成本和延迟是核心瓶颈。一个常见的错误是认为可以随意修改或重组上下文,比如在前面插入一个动态变化的时间戳,或为了"整洁"而删除某些历史步骤。这些操作会破坏模型用于加速计算的"草稿纸"——KV 缓存。 3. 解决方案与工作原理: 将每一次与模型的交互都视为一个不可变的历史记录。任何新的信息(动作、观察)都必须被追加到现有上下文的末尾,而不是在中间插入或修改。这样可以确保每次调用都有一个尽可能长、完全不变的"前缀",模型可以直接从缓存中加载这部分内容的计算结果,只需处理真正新增的部分。 详细实践要点: 1)保持提示前缀稳定 (Keep your prompt prefix stable) 你以为你在做:[固定前缀] + [历史记录] + [新内容] 实际发生的问题:[每次都变的前缀] + [历史记录] + [新内容] 这里的"前缀"通常指系统提示(System Prompt),也就是你给模型设定的初始指令。 典型错误示例:时间戳就是罪魁祸首。假设你的系统提示是: 1 "现在是 2023-10-27 10:30:05。你是一个乐于助人的助手。..." 下一次调用时,它变成了: 1 "现在是 2023-10-27 10:30:08。你是一个乐于助人的助手。..." 看!虽然你只是想追加新内容,但因为时间戳变了,导致整个上下文的第一个 token 就变了。这会让上一次调用的所有 KV 缓存全部失效,命中率直接归零。 所以,这一点的意思是:要实现真正的"追加",你必须保证被追加的那个"母体"(即前缀和历史记录)是一字不差、完全稳定的。 2)使你的上下文仅追加 (Make your context append-only) 这一点分了两部分,后半部分尤其关键: 避免修改之前的操作或观察:这是对"追加"原则的重申。 确保你的序列化是确定性的 (Ensure your serialization is deterministic):这是个非常隐蔽的陷阱! 序列化的意思是把你程序中的数据结构(比如一个 JSON 对象)转换成一串文本(string),以便发送给模型。 不确定性的意思是很多编程语言在把 JSON 对象转成字符串时,不保证 key 的顺序。 例子: 假设你的 Action 1 是一个 JSON 对象:{ "tool": "calculator", "input": "2+2" } 第一次调用时,它被序列化成字符串:'{"tool": "calculator", "input": "2+2"}' 第二次调用时,虽然数据没变,但可能因为语言库的内部实现,它被序列化成了:'{"input": "2+2", "tool": "calculator"}' 对于你和你的程序来说,这两个 JSON 代表的是完全相同的信息。但对于只认文本流(token stream)的 LLM 来说,这是一个完全不同的字符串!这个微小的顺序变化,就会导致从 Action 1 开始的所有缓存全部失效。 所以,这一点的意思是:即使你的逻辑是"追加",你也要保证你用来表示数据的文本格式是100%稳定不变的,否则追加就失去了意义。 3)在需要时明确标记缓存断点 (Mark cache breakpoints explicitly) 这一点是关于工具和框架的限制。 你以为系统会自动帮你缓存:你发送了 [A, B, C],下次发送 [A, B, C, D],你期望系统自动缓存 [A, B, C]。 实际情况:有些推理框架(特别是自托管或某些特定 API)比较"笨",它不知道你的意图。你必须明确地告诉它:“嘿,[A, B, C] 这部分是可以被缓存的,请你记住它!” 这个"明确告知"的动作,就是插入一个缓存断点。 所以,这一点的意思是:你的"追加"策略需要你所使用的工具支持。如果工具不支持自动缓存,你就必须按照工具的规则手动标记,否则你的追加意图就无法转化为实际的缓存效果。 4. 这样做的好处: 显著降低延迟:大大减少了"首个 token 生成时间(TTFT)",使代理响应更迅速。 大幅削减成本:云服务商对缓存命中的 token 收费极低(例如便宜10倍),遵循此原则能最大化节省开支。 提升系统吞吐量:更快的单次处理速度意味着更高的并发能力。 5. 关键引文: “幸运的是,具有相同前缀的上下文可以利用 KV 缓存,这大大减少了首次生成标记时间(TTFT)和推理成本——无论你是使用自托管模型还是调用推理 API。(Fortunately, contexts with identical prefixes can take advantage of KV-cache, which drastically reduces time-to-first-token (TTFT) and inference cost…)” 二、遮蔽,而非移除 (Mask, Don’t Remove) 核心思想:当 AI 代理拥有大量工具时,不要通过动态地从上下文中添加或删除工具定义的方式来管理它们,而应该使用一种更智能的"遮蔽 (Masking)“技术。以及如何利用了模型提供商支持的"响应预填充 (Response Prefill)“功能,来高效地引导和约束模型的行为。 1. 问题背景: 当代理拥有的工具非常多时,一个自然的想法是动态地向上下文增删工具定义,以缩小模型的选择范围。但这种做法是"致命的”,因为它不仅会因为修改上下文前部内容而破坏 KV 缓存,还可能在模型看到历史中使用了某个已被移除的工具时,引发逻辑困惑和错误。 一个看似合理但错误的解决方案:动态加载工具 既然工具太多会干扰模型,那我能不能只在需要的时候才告诉模型有哪些工具可用?比如,当用户提到"计算"时,我才把"计算器"工具的定义加入到上下文中。当任务完成后,再把它移除。这听起来很像 RAG(检索增强生成)按需加载知识的思路。 为什么这是错的? 通过实验发现,这种"动态添加/移除工具"的方式会带来两个严重问题: 破坏 KV 缓存 工具的定义(告诉模型这个工具叫什么、怎么用)通常被放在上下文的最前面,紧跟在系统提示(System Prompt)后面。 想象一下上下文的结构:[系统提示] [工具A, 工具B的定义] [历史对话...] 如果在中途你移除了工具A,上下文就变成了:[系统提示] [工具B的定义] [历史对话...] 看到了吗?上下文的前缀发生了改变!这会导致从这个改动点之后的所有内容的 KV 缓存全部失效。每一次你增删工具,都相当于进行了一次高成本、高延迟的"冷启动”,完全抵消了动态加载带来的好处。 让模型感到困惑(逻辑上的矛盾) 想象一下历史记录里有这样一步:Action: 使用了工具A。 现在,你为了"优化",在新的上下文中把工具A的定义给删掉了。 模型在处理新的上下文时,会读到历史记录里的"使用了工具A",但当它去查找工具A的定义时,却发现它不存在了! 这会让模型彻底懵掉。它可能会开始"胡言乱语"(幻觉动作,hallucinated actions),或者生成不符合你预定格式的输出(模式违规,schema violations)。 2. 解决方案与工作原理: 既然不能从上下文中移除工具定义,那该怎么办呢?答案是:工具定义一直都在,但我们在模型生成答案的最后一步进行干预。 核心思想:让模型在完整的、包含所有工具定义的上下文下进行思考。但在它即将要从词汇表中选择下一个 token(也就是决定要用哪个工具)的那一瞬间,我们人为地进行干预。 通过"响应预填充"技术(如预先填好 {"name": "browser_)或使用 Logits 处理器来强行降低(或提升)特定工具对应 token 的选择概率。 1)什么是"遮蔽 Token Logits"? Logits:在生成下一个 token 时,模型会为词汇表中的每一个词计算一个分数(logit),这个分数代表了选择这个词的可能性有多大。 遮蔽 (Masking):就是我们强行修改这些分数。如果我们不希望模型选择"工具A",我们就把"工具A"对应 token 的 logit 分数设置成一个极小的值(比如负无穷)。这样一来,模型就绝对不会选择它了,即使它本来觉得"工具A"是最佳选项。反之,我们也可以强制它选择某个工具。 如何实现:用一个"上下文感知的状态机 (context-aware state machine)“来管理工具的可用性。这个状态机根据当前的对话状态,来决定哪些工具是"可用的”,哪些是"禁用的"。 例如,如果用户刚提供了航班信息,状态机就把"订票工具"设为可用,把"计算器工具"设为禁用。然后,在模型生成时,这个状态机的决策就会被用来"遮蔽"掉那些被禁用的工具对应的 token logits。 2)响应预填充 (Response Prefilling) 这是一种非常有用的技术。通常情况下,我们给模型一个输入(Prompt),然后等待它从零开始一个字一个字地生成输出。而"响应预填充"允许我们替模型先把回复的开头写好,然后让模型接着这个开头继续写。 例如,我们给模型的输入是:“中国的首都是哪里?” 普通调用:模型会自己生成:“中国的首都是北京。” 使用预填充:我们可以给模型同样的输入,但同时告诉它:“你的回复必须以 中国的首都是 开头。” 然后模型只需要接着生成 “北京。” 就行了。 这个简单的功能,结合不同的预填充内容,就演变成了下面三种强大的"函数调用模式"。 比如: <|im_start|>assistant 是一个特殊的标记(token),告诉模型:“现在轮到你(助手)说话了。” 自动 (Auto) 预填充内容:只预填充 ...<|im_start|>assistant 效果:我们只是告诉模型"到你说了",但没说你要说什么。所以模型可以自由选择是直接回答用户(生成普通文本),还是调用一个工具(生成 <tool_call> 格式的文本)。这是最自由的模式。 必需 (Required) 预填充内容:预填充到 ...<|im_start|>assistant<tool_call> 效果:我们替模型把"我要调用工具"这句话 (<tool_call>) 都写好了。模型被强制必须调用一个工具,它唯一的选择是接下来该调用哪个工具。 指定 (Specified) 预填充内容:预填充到 ...<|im_start|>assistant<tool_call>{"name": "browser_ 效果:这是最强的约束!我们不仅强制模型必须调用工具,还把工具的名字都替它写了一半(比如 browser_)。现在,模型被强制必须从所有以 browser_ 开头的工具中选择一个。 3)如何利用这些模式实现智能"遮蔽"? 场景:当用户刚发来一条新消息时,代理(Manus)的规则是必须先回复用户(比如"好的,我正在处理"),而不是立刻去执行工具。 实现:他们可能会使用 Auto 模式,并结合 logits masking,“遮蔽"掉 <tool_call> 这个 token。这样,模型就无法选择调用工具,只能生成普通文本回复。 强制选择某一类工具: 设计:他们非常有远见地设计了工具的命名规则,比如所有浏览器工具都以 browser_ 开头,所有命令行工具都以 shell_ 开头。 场景:假设代理的状态机判断当前需要操作浏览器。 实现:他们直接使用 Specified 模式,将回复预填充到 ...<tool_call>{"name": "browser_。这样,模型甚至都不需要思考该用什么类型的工具,它的选择范围被直接缩小到了所有浏览器工具中。这是一种极其高效的引导。 4)“无需使用有状态的 logits 处理器"是什么意思? 有状态的 logits 处理器 (Stateful logits processors):这是一种比较"重"的实现方式。它需要一个复杂的程序(处理器)在模型生成的每一步都去检查当前的状态,然后动态地计算哪些 token 应该被遮蔽。这需要维护很多状态信息,实现起来比较复杂。 Manus 的更优解:通过巧妙的命名规范(如 browser_)和预填充技术,他们在很多情况下避免了这种复杂的逐词(token-by-token)判断。他们可以直接在调用 API 的时候,通过设置预填充内容,一次性地、粗粒度地就把模型的选择范围给框定了。这比精细地去控制每一个 token 的 logits 要简单和高效得多。 3. 这样做的好处: 保证了 KV 缓存的完整性,兼顾了性能与灵活性。 避免了逻辑矛盾,模型不会因找不到历史工具的定义而困惑。 实现了精确的动态控制,可以在恰当的时机引导模型做出正确的决策。 4. 关键引文: “在大多数 LLMs 中,工具定义在序列化后位于上下文的前部… 因此,任何更改都会使所有后续动作和观察的 KV 缓存失效。(In most LLMs, tool definitions live near the front of the context after serialization… so any change will invalidate the KV-cache for all subsequent actions and observations.)” 三、将文件系统用作上下文 (Use the File System as Context) 核心思想:与其试图把所有信息都塞进模型有限的上下文窗口,不如把文件系统当作一个无限大、可持久化的"外部记忆"或"扩展上下文”,并教会 AI 代理自己去管理和使用这个外部记忆。 1. 问题背景: 前沿模型的上下文窗口虽大,但在处理网页、PDF、代码库等海量非结构化数据时仍捉襟见肘。强行塞入长上下文不仅成本高昂,还会因"迷失在中间"效应导致模型性能下降。而传统的截断或摘要方法则存在不可逆的信息丢失风险。 观察内容过大 (Observations can be huge) 场景:代理需要"阅读"一个网页、一份 PDF 报告或者一段代码库。这些内容的 token 数量可能轻易就达到几十万甚至上百万,一次就足以撑爆任何模型的上下文窗口。 痛点:你无法把所有原始信息都提供给模型。 长上下文性能下降 (Performance degradation) 现象:著名的"迷失在中间 (Lost in the Middle)“问题。当上下文非常长时,模型对放在开头和结尾的信息记得最清楚,但很容易忘记或忽略中间部分的信息。 痛点:即使你勉强把所有信息都塞进去了,模型的推理能力和准确性也会下降。它可能会"看不到"中间的关键信息。 成本高昂 (Long inputs are expensive) 原因:模型 API 的计费是按 token 数量来的。即使有 KV 缓存,每次调用时,新追加的那部分内容(Prefill)仍然需要付费。如果每次追加的内容都很长(比如一个网页的 HTML),成本会迅速飙升。 痛点:维持一个巨大的上下文窗口在经济上是不可持续的。 2. 解决方案与工作原理: 改变范式,不再将上下文作为唯一的"信息容器”。当遇到海量数据时,让代理调用 writeFile 工具将其存为本地文件。在上下文中,只保留一个轻量级的"指针”(如文件路径或URL)。当后续需要时,代理可以再调用 readFile 等工具来访问这些信息。 传统解决方案的局限性:压缩与截断 面对上述问题,常见的解决方案是"想办法把上下文变短": 截断 (Truncation):简单粗暴地砍掉最前面或中间的内容。 压缩 (Compression):用一个总结(summary)来代替冗长的原文。 为什么这些方法有根本性的风险? 信息丢失是不可逆的。你永远无法预知,被你砍掉或总结掉的某一个细节,会不会在十步之后成为解决问题的关键。 代理的本质:AI 代理需要基于所有历史状态来做决策。任何信息的丢失都可能导致它做出错误的判断。比如,你删掉了一个早期的错误尝试,模型可能在后面又犯一遍同样的错误。 Manus 的解决方案:文件系统即终极上下文 既然把所有东西都记在模型的"短期记忆"(上下文窗口)里既不可靠又昂贵,Manus 团队提出了一种更优雅的范式。 将文件系统(File System)提升到"上下文"的地位。 好处: 大小无限 (Unlimited in size):硬盘空间几乎是无限的,可以存下任何大小的网页、PDF 或日志。 持久化 (Persistent by nature):即使代理程序重启,信息也不会丢失。它就像一个真正的大脑,记忆是永久的。 代理可直接操作 (Directly operable):最关键的一点!教会模型使用 writeFile, readFile, ls 等工具。 如何工作: ...

July 20, 2025 · Estimated Reading Time: 4min · Plutoxx28