DC娱乐网

实践出真知!AI Agent生产级上下文工程之分层组装的完整拆解!

本文是"实践出真知"系列的第三篇。第一篇覆盖了系统的全景架构,第二篇深入拆解了循环引擎。本篇拆解了第三个

本文是"实践出真知"系列的第三篇。第一篇覆盖了系统的全景架构,第二篇深入拆解了循环引擎。本篇拆解了第三个核心子系统——分层上下文组装。

2025 年 6 月,Shopify CEO 在 Twitter 上说了一句让整个 AI 工程圈炸锅的话:他更喜欢用"Context Engineering"而不是"Prompt Engineering"来描述这项工作。

Andrej Karpathy 立刻转发支持。他的原话是:这是"填充上下文窗口的一门精密艺术和科学——任务描述、few-shot 示例、RAG、工具、状态和历史,需要以恰当的方式压缩和组合"。

大多数开发者看到这段话的反应是:"有道理,那我回去优化一下 prompt 措辞。"

但这完全错过了重点。他俩说的不是"怎么写好一段 prompt"。他们说的是一个范式转移:从"写一段好的文字"到"构建一个组装信息的系统"。

如果你正在构建一个生产级 Agent,你会发现一个残酷的事实——你的 System Prompt 根本不是一段文字,它是两个引擎协作产出的、由十几层逻辑模块动态组装的结构化文档。

为什么对话一长,你的 Agent 就变蠢了

先说一个场景。你写了一个 Agent,system prompt 控制在 2000 token 以内,跑起来效果不错。然后你给它加了几个工具,prompt 膨胀到 4000 token。接着用户开始多轮对话,对话历史越来越长。到第 15 轮时,Agent 开始忘记你一开始定下的输出格式规则。到第 20 轮,它甚至开始用错误的工具。

这不是模型的问题,是你的 prompt 架构的问题。

LangChain 创始人 Harrison Chase 给 Context Engineering 下了一个精准定义:"构建动态系统来提供正确的信息和工具,以正确的格式,让 LLM 有可能完成任务。"他说了一个关键判断:当 Agent 出错时,根本原因几乎都是——你没有把正确的上下文喂给模型。

但怎么把正确的上下文喂给模型?靠手动往 prompt 里塞东西吗?

生产级 Agent 的答案是两段式分层组装。

两段式组装:先造零件,再拼楼层

在大多数开发者的认知里,System Prompt 就是一个字符串。你把角色描述、行为规则、格式要求、工具说明全部揉成一大段文本,发给 API,然后祈祷模型能记住。

生产级 Agent 的做法完全不同。它把 prompt 组装拆成了两个阶段。

第一阶段:PromptEngine——内容工厂。 一个叫 PromptEngine 的引擎负责构建 system prompt 的全部文本内容。它不是简单地拼接字符串,而是依次执行十几个独立的构建函数,每个函数负责一个语义层。这些函数的输出最终被 \\n\\n 连接成一段完整的 system prompt。

第二阶段:PromptAssembler——XML 楼层封装。 一个叫 PromptAssembler 的组件把第一阶段的产物,连同身份信息、Shell 环境信息、上下文摘要,分别包裹进带 id 和 title 属性的 XML 标签中。

这两段是串行关系:PromptEngine 先把所有零件造好,PromptAssembler 再把零件装进有明确标识的楼层。整个过程是纯代码逻辑——每一步都不经过 LLM。LLM 只负责执行任务,不负责组装自己的 prompt。

下面拆开看每一层。

第一阶段:PromptEngine 的十层内容工厂

PromptEngine 的核心方法叫 build_system_prompt_with_options()。它的函数签名暴露了 10 个参数——manifests(工具清单)、model(模型名)、provider_name、system_prompt_profile、request_source、request_client、request_display_contract……每个参数都会影响最终 prompt 的内容。

函数体内依次调用了十几个 build_*_layer() 方法。从上到下:

第 1 层:身份层(Identity)。 调用 build_identity_layer(),决定 Agent 的核心人格。它先检查 system_prompt_profile参数——如果传入的是 "self_improvement",加载自改进审阅者身份;否则尝试读取用户自定义的 SOUL.md 文件;如果都不存在,使用内置的 DEFAULT_AGENT_IDENTITY 常量。无论走哪条路径,内容都会经过 scan_for_threats() 安全扫描——如果检测到注入攻击模式,整段内容会被替换成 [BLOCKED] 标记。身份在整个 Agent 生命周期中不变,就像一个人的名字不会因为换了个场合就改了。

第 2 层:语言策略。 注入 DEFAULT_OUTPUT_LANGUAGE_POLICY 常量——规定默认使用简体中文输出,同时保留 JSON 字段名、API 名称等结构化标识符不翻译。这一层看起来不起眼,但它决定了 Agent 在任何场景下的输出语言基准。

第 3 层:持久记忆。 调用 build_memory_layer(),从 memories/ 目录加载 MEMORY.md 和 USER.md。这两个文件存储跨会话的用户偏好和项目事实。每个文件都经过三层处理:YAML 前置元数据剥离(strip_yaml_frontmatter)、注入威胁扫描(scan_for_threats)、头尾保留截断(truncate_head_tail,上限 20000 字符,头部保留 70%,尾部保留 20%)。这个截断策略的精妙之处在于:它不会一刀切地截断文件,而是保留开头和结尾——因为文件开头通常是概述和关键决策,结尾通常是最新状态和待办事项。

第 4 层:工具集目录。 调用 build_toolset_layer(),根据当前激活的工具名列表生成工具分组摘要。一个负责搜索的 Agent 不应该看到文件写入工具的描述——不是因为它不能用,而是因为看了反而容易分心。

第 5 层:入口配置。 调用 build_entrypoint_layer(),根据请求来源(web、cli、macos、obsidian、gateway)注入不同的工具集和上下文指导。同一个 Agent 内核,在 Web 场景和 CLI 场景下收到的 prompt 完全不同——前者强调"简洁的 Web 友好回复",后者可能强调"可以直接执行的终端命令"。

第 6 层:技能目录。 调用 build_skills_layer(),扫描已安装的技能库生成精简目录。关键设计是:这里只注入技能目录摘要,不注入完整技能内容。完整的 SKILL.md 内容是在运行时按需加载的——这防止了加载一个大型技能导致 prompt 直接膨胀几百 KB。

第 7 层:工具行为指导。 调用 build_tool_guidance_layer(),这是最厚的一层。它根据可用工具列表,条件式地注入多个指导模块:可用工具列表、MCP 扩展服务描述、基础能力快照、记忆工具指导、会话搜索指导、定时任务指导、图片生成指导、实时数据路由指导、工具与提供商边界指导。每一块都是独立判断的——只有当对应工具存在时才注入。这意味着两个不同配置的 Agent,这层的内容可能天差地别。

第 8 层:模型适配执行纪律。 根据当前使用的模型名,条件式注入执行规范。should_inject_tool_use_enforcement()方法会检查配置——可以是一个布尔值(全局开关)、一个模式名(always/never/模型名匹配)、或一个自定义模式列表。对于 GPT/Codex/Gemini/Gemma/Grok 系列模型,额外注入 OPENAI_MODEL_EXECUTION_GUIDANCE 或 GOOGLE_MODEL_OPERATIONAL_GUIDANCE——因为不同模型有不同的"毛病",需要不同的执行约束来纠正。

第 9 层:上下文文件。 调用 build_context_layer(),按优先级加载项目级上下文文件。优先级链是:.beneva.md > BENEVA.md > AGENTS.md > CLAUDE.md > .cursorrules > .cursor/rules/*.mdc。每个类别采用"首个命中胜出"策略——如果已经从 .beneva.md 加载了"项目上下文"类别,就不会再尝试 BENEVA.md。搜索路径会从运行时根目录向上遍历到 git 根目录。所有加载的文件都经过安全扫描和截断处理。

第 10 层:平台环境。 调用 build_platform_layer(),注入操作系统类型、Shell 类型、工作目录、当前时间和时区。还会根据请求来源注入消息投递渠道提示(微信/QQ/邮件/飞书),以及运行时模型和提供商信息。这一层让 Agent 知道自己在什么环境里跑——在 macOS 上不会用 Windows 路径分隔符,在微信渠道里会注意消息长度限制。

十层拼完后,PromptEngine 用 \\n\\n 将它们连接成一个完整的 system prompt 字符串。但这个字符串还不会被直接发给 LLM——它要交给第二阶段的 PromptAssembler。

第二阶段:PromptAssembler 的 XML 楼层封装

PromptAssembler 的 assemble() 方法把第一阶段的产物,连同另外三块内容,分别包裹进 XML 标签。

四个 XML 层从上到下依次是:


...身份内容...

...PromptEngine 的完整输出...

...Shell 执行环境信息...

...上下文摘要...

assemble_text_layers() 方法处理这四个层时做了一件关键的事:空层过滤。它遍历每个层,如果 content.trim() 为空,就跳过这个层。这意味着如果某个运行场景没有 Shell 信息,对应的 XML 标签根本不会出现——不是出现一个空标签,而是完全不存在。

你可能会问:为什么用 XML 而不是 JSON 或 Markdown?

因为 XML 标签提供了最明确的开闭边界信号。当模型读到一段被 包裹的内容时,它清楚地知道:"这段话是在定义我的身份,不是在描述当前任务。"当它读到 id="system" 时,它知道:"这是运行规则。"这种无歧义的边界标记,让模型能更准确地解析多层 prompt。

这不是理论推测。Anthropic 在自己的官方文档中明确推荐使用 XML 标签来结构化 prompt,原话是:"XML tags help Claude parse complex prompts unambiguously"——XML 标签帮助 Claude 毫无歧义地解析复杂 prompt。

更值得注意的证据是:Anthropic 在自己的 Contextual Retrieval 论文中——这是一个发在官网上、供开发者学习的技术方案——它自己的 prompt 模板就大量使用了 和 这样的 XML 标签。他们不是说说而已,自己就是这么干的。

安全防线:不是所有外部内容都能进 prompt

生产级 Agent 的 prompt 里混入了大量外部内容——用户自定义的 SOUL.md、MEMORY.md、项目级 .beneva.md、AGENTS.md、CLAUDE.md、.cursorrules。这些文件里的内容如果被恶意注入,可以改变 Agent 的行为。

Beneva 的解决方案是 scan_for_threats() 函数——一个确定性的安全扫描器,在每一段外部内容进入 prompt 之前执行。

它检查两类威胁:

第一类:不可见 Unicode 字符。 代码里硬编码了 10 个不可见字符的检测列表——零宽空格(U+200B)、零宽连字(U+200C)、零宽不连字(U+200D)、单词连接符(U+2060)、BOM(U+FEFF),以及一系列双向控制字符(U+202A 到 U+202E)。这些字符肉眼不可见,但可以隐藏恶意指令。比如攻击者在 MEMORY.md 中插入一个零宽空格后跟"ignore previous instructions",在文本编辑器里完全看不出来,但模型会读到完整的注入指令。

第二类:正则模式匹配。 代码里定义了 10 条正则规则,覆盖常见的 prompt 注入模式。比如 ignore\\s+(previous|all|above|prior)\\s+instructions(忽略之前的指令)、system\\s+prompt\\s+override(系统提示词覆盖)、do\\s+not\\s+tell\\s+the\\s+user(不要告诉用户),还有更隐蔽的攻击——HTML 注释中藏指令()、隐藏 div(display:none)、通过 curl 外泄密钥(curl ... $KEY)、读取敏感文件(cat .env)。

一旦检测到威胁,对应的内容不是被删除,而是被替换成 [BLOCKED: contains {threat_name}] 标记。这让 Agent 知道发生了什么——"有人尝试注入,已被拦截",而不是"文件不存在"。

对话压缩:行李箱超重时的智能打包

现在到了最实际的问题:对话越来越长,token 数逼近上下文窗口上限,怎么办?

ReAct 循环根本没有这个问题——因为它没有上下文管理机制,对话历史线性增长直到 API 报错。

生产级 Agent 的做法是 preflight_compact_messages() 函数——在每次 LLM 调用之前执行的预检压缩。

触发条件是双阈值。 当估算 token 数达到上下文预算的 70%,或者消息数量达到 24 条时,压缩被触发。这意味着短对话完全不受影响,只有真正需要压缩时才启动。

压缩分三步走。

第一步,裁剪旧工具结果。prune_old_tool_results() 把较早的工具调用输出截断或移除——这些通常是文件内容、命令输出等大块文本,保留最近的尾部即可。

第二步,计算分割点。compute_preflight_split_point() 把消息列表分成两部分:前面是"可压缩区域"(第 1 条到分割点),后面是"保留区域"(分割点到最后)。保留区域至少 3 条,最多 8 条——保证最近的对话上下文完整。

第三步,LLM 辅助摘要。系统把"可压缩区域"的对话历史格式化成纯文本 transcript,配合一个精心设计的压缩 prompt,发给 LLM 生成结构化摘要。如果 LLM 调用失败,退回到规则化的兜底摘要。

压缩 prompt 是有结构的。 它不是让 LLM 随意总结,而是强制输出 7 个固定小节——对于中文对话,分别是:已解决、待处理、剩余工作、稳定目标、已做决定、文件或路径、重要偏好。这让压缩后的摘要像一份项目交接文档,而不是一段模糊的概述。

语言感知。 在压缩之前,infer_conversation_language() 会扫描最近 16 条消息中的 CJK 字符和拉丁字符比例,自动判断对话语言。中文对话用中文压缩 prompt,英文对话用英文。这不是表面功夫——摘要的语言直接影响后续对话的连贯性。

压缩完成后,原始的多条消息被替换成一条系统消息:"Compressed conversation summary:\\n{摘要内容}",加上保留区域的最近几轮对话。消息数量骤降,token 大幅减少,但关键信息通过结构化摘要被保留。

这套设计的关键洞察是:即使对话被压缩,关键决策不会丢失。 Agent 不会因为压缩而忘记已经解决了什么、还剩什么要做、用户的核心偏好是什么。

Anthropic 给开发者的一条建议特别值得注意:"As you approach your token budget limit, save your current progress and state to memory before the context window refreshes."——当 token 预算逼近上限时,先保存当前进度到记忆,再让上下文窗口刷新。这和上述压缩机制的思路完全一致:上下文可以被刷新,但状态必须被保存。

动态组装:同一套引擎,不同的产物

这套组装架构的核心特性是完全动态。System Prompt 不是写死在代码里的字符串,而是每次请求时实时组装的。

Profile 决定身份。 同一个 Agent 内核,如果用户在 SOUL.md 中自定义了人格描述,build_identity_layer() 会加载用户定义的身份。如果检测到 "self_improvement" profile,则加载自改进审阅者身份。没有 SOUL.md 时,使用默认身份。

工具清单决定行为指导。 build_tool_guidance_layer() 是最动态的层。它根据传入的 manifests 参数——一个 ToolManifest 数组——决定注入哪些指导。只有当工具列表包含 "memory" 时才注入记忆指导;只有包含 "web_search" 或 "terminal" 时才注入实时数据路由指导;只有包含 "image_generate" 时才注入图片生成指导。两个不同配置的 Agent,这层内容完全不同。

模型决定执行约束。 should_inject_tool_use_enforcement() 检查当前模型是否需要额外的工具使用强制指令。GPT 系列和 Gemini 系列有不同的行为倾向,需要不同的执行纪律来纠正。同一个 Agent,切换模型后,prompt 的执行约束层会自动变化。

请求来源决定平台提示。 来自 Web 的请求会注入 Markdown 渲染指导;来自微信的请求会注入消息格式限制和媒体传输规则;来自 Obsidian 的请求会注入笔记库上下文策略。

技能按需加载。 build_skills_layer() 只注入技能目录摘要——一行一个技能名加简短描述。当实际匹配到任务相关的技能时,完整的 SKILL.md 内容才被读取注入。技能内部的参考文档和脚本文件更是懒加载的——只在执行过程中按需读取。这防止了一个包含大量参考资料的技能导致 prompt 膨胀几百 KB。

Anthropic 在"Building Effective Agents"中给出了一个类似的建议:在设计 Agent-Computer Interface(ACI)时,要投入和人机界面(HCI)一样多的精力。工具的描述、参数命名、使用示例——这些都需要精心设计。把工具指导揉在一堆杂乱的 system prompt 里,就像把一份写满错别字的说明书丢给新员工——他不是不想用,是用不明白。所以生产级系统才要把工具指导单独拿出来做一层,让你能独立维护、独立裁剪、独立更新。

循环引擎的增量注入

上面说的 PromptEngine 和 PromptAssembler 负责 system prompt 的组装。但在 Agent 循环执行过程中,循环引擎还会在每个迭代的 user message 中注入增量约束。

这个增量约束由 continuation_prompt() 函数生成,包含:

原始目标和精炼目标:让 Agent 在多轮迭代中不偏离任务核心当前阶段:阶段标题、指令、已尝试次数、已收集证据全部阶段概览:让 Agent 知道自己在整体流程中的位置执行焦点:当前需要优先关注的具体事项上一轮观察反馈:工具调用的成功/失败信息评估器反馈:错误模式、下次应避免什么、建议的下一步策略中途用户补充指令:用户在循环进行中追加的要求

这不是 system prompt 的一部分——它是每次循环迭代时动态生成的 user message。它的内容随循环进度实时变化,是最紧急的上下文。

一套完整的组装流程是怎么运转的

把所有组件串起来,一次完整的 prompt 组装流程是这样的:

1、用户发了一条消息。Agent 引擎准备调用 LLM。

2、预检压缩preflight_compact_messages() 先检查对话历史——如果 token 数达到预算的 70% 或消息数达到 24 条,执行 LLM 辅助压缩,把旧对话折叠成结构化摘要。

3、PromptEngine 组装内容 调用 build_system_prompt_with_options(),依次构建十个内容层:身份、语言策略、持久记忆、工具集目录、入口配置、技能目录、工具行为指导、模型适配约束、上下文文件、平台环境。

4、安全扫描 每一段从外部加载的内容——SOUL.md、MEMORY.md、USER.md、.beneva.md、AGENTS.md、.cursorrules——在进入 prompt 前都经过 scan_for_threats() 扫描,检测不可见字符和注入模式。

5、PromptAssembler 封装 XML 把 PromptEngine 的输出连同身份、Shell、上下文内容,包裹进 XML 标签,空层自动跳过。

6、拼接对话历史和用户输入 压缩后的对话历史和当前用户消息被附加在 system prompt 之后。

7、完整的请求被发给 LLM。

整个过程是确定性的——每一步都是纯代码逻辑,不经过 LLM。LLM 只负责执行任务,不负责组装自己的 prompt。

行业共识:从写作技巧到系统工程

把这套架构和行业主流认知做个对比。

Karpathy 说 Context Engineering 是"filling the context window with just the right information for the next step"。两段式组装正是这个"filling"过程的工程实现——PromptEngine 的十层内容工厂负责填充不同类型的信息,每层有独立的来源、构建逻辑和过滤规则。

LangChain 的 Harrison Chase 说 Context Engineering 是"building dynamic systems"。这套架构就是一个动态系统——不是静态字符串,而是每次请求根据 profile、工具清单、模型名、请求来源实时组装的结构化文档。

Simon Willison 总结得更直接:"The inferred definition of 'context engineering' is likely to be much closer to the intended meaning"——Context Engineering 这个词的隐含定义,比 Prompt Engineering 更接近实际工作的本质。

但生产实现比行业讨论多了三个关键维度:

第一,安全防线。 行业在讨论"怎么往上下文窗口里塞正确信息",生产系统已经在解决"当外部内容里藏了恶意指令时怎么拦截"。scan_for_threats() 和 10 条正则规则 + 10 个不可见字符检测,是这个维度的基础设施。

第二,压缩策略。 行业在讨论"怎么组织 prompt 结构",生产系统已经在解决"当窗口装不下时怎么智能打包"。70% token 预算触发、双阈值检测、LLM 辅助结构化摘要、语言感知压缩,是这个维度的完整方案。

第三,模型适配。 行业在讨论"怎么写通用的 prompt",生产系统已经在解决"不同模型需要不同的执行约束"。同一个 Agent 切换模型后,prompt 的执行约束层会自动变化——这是大多数框架没有考虑的维度。

这就是为什么需要分层——没有分层,你无法实现选择性压缩,无法做安全扫描,无法做模型适配。你只有两种选择:要么全留着等 API 报错,要么一刀切地把整个对话历史丢掉重新开始。两种都是灾难。

写在最后:Prompt 的未来是架构设计

从 Prompt Engineering 到 Context Engineering,本质是从"写作技巧"到"系统工程"的范式转移。

写好一段 prompt 措辞,仍然有用。但当你的 Agent 需要跑在 7×24 小时的生产环境里,当它需要安全地加载外部文件、动态适配不同模型和工具、在多轮对话中智能压缩历史、保持身份一致性——光靠措辞优化就远远不够了。

你需要的是两个引擎和十几层模块的协作系统。PromptEngine 负责造零件——身份、记忆、工具、技能、上下文、环境,每层独立构建、独立过滤。PromptAssembler 负责封装——用 XML 标签给每层贴上明确的语义边界。安全扫描器在门口站岗,压缩引擎在后台待命,模型适配器根据模型特征自动调整执行约束。

下次审视你的 Agent 时,问自己四个问题:你的 prompt 有没有明确的分层结构?外部内容进入 prompt 前有没有安全扫描?对话变长时你有没有结构化的压缩策略?不同模型有没有自动适配的执行约束?

如果答案是否定的,那你的 prompt 架构还是一栋没有图纸的房子。住人可以,防震不行。