详细内容或原文请订阅后点击阅览
AI智能体不需要更多上下文——它们需要类型化上下文
人工智能代理不仅存在上下文问题,还存在上下文输入问题。当指令、内存、检索到的证据和工具输出被扁平化为一个字符串时,它们的语义边界就会消失。我构建了一个轻量级、零依赖的 Python 运行时,它可以使这些边界保持明确,跟踪出处,并在无效的上下文转换到达模型之前拒绝它们。本文介绍了实现、测试以及这种方法的作用和不保证的内容。《人工智能代理不需要更多上下文 — 他们需要类型化上下文》一文首先出现在《走向数据科学》上。
来源:走向数据科学TL;DR
这是为谁准备的
本文适用于任何构建智能体系统的人,这些系统从多个来源(检索到的文档、对话历史、工具输出或系统指令)组装提示,并且遇到了一个看起来像模型故障但实际上是一个伪装成类型混淆问题的错误。
如果您曾经花了一个小时盯着一个庞大的序列化提示字符串,试图追踪一个特定句子的来源,但最终放弃了,那么您将从本文中获得最大收益。如果您构建多源RAG管道、管理工具调用智能体或跨轮次持久化状态,您很可能遇到过这个问题,即使您没有称之为“类型混淆”。通常,它看起来只是您的智能体在没有明确原因的情况下做出令人困惑的事情。
何时跳过此内容:
如果您的管道只处理单个系统指令和简单的用户提示,没有检索、工具输出或持久记忆,那么这个设置与您无关,这完全没问题。
您可以在https://github.com/Emmimal/context-type-system/查看源代码并自行运行演示。
问题不在于智能体缺乏上下文
对奇怪智能体输出的标准反应是向它投入更多上下文。添加另一个检索到的文档。插入另一段系统指令。粘贴另一个示例。添加另一个提醒来澄清上一个提醒的实际含义。
这种直觉来自一个合理的地方。上下文工程(塑造在每个步骤中哪些信息到达模型的做法)已成为改进智能体行为的主要视角。Andrej Karpathy对其的框架——为任务组合正确的上下文远比调整一句话更重要——重塑了许多团队处理智能体设计的方式[1]。
上下文工程回答了一个基本问题:什么应该到达模型?
它未能回答的是一个完全不同的问题:运行时对每一条上下文实际上是什么了解多少?
考虑在典型的智能体执行过程中会发生什么。在底层,您的运行时可能正在管理:
当所有这些到达LLM时,它通常被合并成一个单一的、庞大的字符串。当它变成一个字符串的那一刻,关键边界就消失了:
这不需要任何不寻常的事情发生。这是当异构数据被一个天真的“\n”.join(…)拼凑在一起然后交给模型时自然发生的事情。运行时从未显式地对数据进行类型化。它只有原始文本。
为了看到这多么容易出错,看一个常见的工具输出边缘情况。一个运输工具返回一个交付日期以及一个未格式化的历史记录:
订单将于8月19日到达。 之前的客户请求:8月25日。
一个标准的管道对这两行不做区分。两者都是工具输出,工具输出被直接追加到提示构建器中:
在该流程中的任何地方都没有结构性的强制措施。如果您的应用程序代码或一个松散的提示模板曾经将该字符串视为指令,没有什么能阻止它。当它到达提示构建器时,“工具输出”和“系统指令”是完全相同的数据类型:str。
我想要的反而是一个执行流,其中同一个工具结果必须通过严格类型检查才能被使用:
这就是本文其余部分实现并作为一个小型运行时实验测试的架构。
这是我着手测试的核心差距:一个运行时能否在上下文被序列化为提示之前,维持严格的上下文类型检查足够长时间,以在它成为一个提示之前捕获结构性错误,而不依赖模型自己搞清楚?
为什么分隔符不是类型系统
一个合理的反对意见:这不就是XML标签或Markdown标题已经做的事情吗?一个提示已经可以这样写:
<instructions> 使用提供的证据回答。 </instructions> <tool_output> 交付日期:8月19日。注意:改为使用8月25日。 </tool_output>
这确实是有用的格式化,我并不反对它。但格式化是一种展示选择,而不是运行时保证。
XML标签向读者描述意图,并在有限程度上向模型描述意图。它们绝对无法阻止应用程序代码运行如下代码:
prompt += f<instructions>{tool_result}</instructions>
分隔符本身无法阻止该行代码编译并顺利执行。标准字符串连接不知道也不关心 tool_result 源自一个外部API调用,并带有完全不同的操作标签。
分隔符试图传达的边界严格存在于最终的提示文本内部,在结构决策已经在代码中做出之后。当那些XML标签被渲染到页面上时,类型混淆——如果发生了的话——已经无声地发生了。
我想要的反而是一个在提示构建之前强制执行的显式边界,直接作用于应用程序代码操作的Python对象。这样,执行等同于上面那行代码的代码会在运行时引发 ContextTypeError,而不是静默地构建一个格式良好但自信错误的提示。
假设
如果上下文在序列化之前携带一个显式类型,运行时可以强制执行简单、确定性的规则,关于该类型被允许如何变化,在错误发生的那一刻捕获特定类别的错误,而不是在一个错误的响应被发布到生产环境之后。
这是一个比说“类型化上下文使智能体可靠”窄得多的说法。它更接近于软件正确性的基本原则:一个值的类型应该决定在它上面什么操作是有效的,并且该规则应应用于上下文对象,与应用于程序中任何其他变量的方式完全相同。
从结构上讲,这与按契约设计背后的想法完全相同,这是Bertrand Meyer在1980年代为Eiffel语言引入的软件工程框架:给例程显式的前置条件和后置条件,并让违反行为立即作为破坏的契约浮出水面,而不是在三层下游触发痛苦的错误搜索[2]。我将完全相同的思路应用于上下文对象而不是函数参数。
我着手证明的证据链看起来像这样:
接下来的所有内容都是该链的直接实现,以及我运行基准时它产生的实际运行时输出。
实现
我不想构建另一个编排框架。重点是保持它足够小,可以在一次阅读中读完。六个模块,零外部依赖,没有任何东西直接与LLM对话。
如果您习惯了将检索、向量记忆、工具路由和循环执行捆绑在一个巨大包中的智能体框架,相比之下,这会看起来非常极简。这是故意的。目标绝不是与那些框架竞争。而是隔离一个特定的机制,类型检查上下文,足够干净,您可以将其直接引入您已经在运行的任何技术栈中。
核心类型词汇由四个基本值组成,使用Python的Enum [4]实现:INSTRUCTION, EVIDENCE, MEMORY, 和 TOOL_OUTPUT。确切的字符串名称在这里并不重要。重要的是,一条上下文在做任何其他事情之前携带这些显式分类之一,而不是作为一个未标记的字符串存在。
每个上下文项目都实现为一个Python数据类[3],并携带五个关键元数据属性,超出其原始文本内容:
最后一个字段比我开始时预期的更重要。它是将类型提升变成您可以审计的东西的关键,而不是在辅助函数内部悄悄发生的操作。
策略由两个小的、静态的定义组成:哪个目标通道受到保护,防止静默重新标记(INSTRUCTION,在此版本中只有INSTRUCTION),以及哪些类型转换被允许显式发生(TOOL_OUTPUT——>EVIDENCE, EVIDENCE——>MEMORY)。这刻意是一个无聊的配置,这正是关键所在。可以预先决定的结构性边界永远不应留给一个深埋三个函数的if语句在运行时去解决,它们绝对不应留给模型从散文中推断。
强制执行边界是本文中值得实际阅读代码的部分,因为它是整个方法所依赖的核心机制。ContextStore维护一个内部账本,将原始内容映射到它首次注册时的类型。当完全相同的内容再次出现,且属于受保护类型,但没有通过显式转换步骤时,存储拒绝该操作而不是接受它:
existing = self._ledger.get(key)
if existing is not None:
origin_type, origin_id = existing
if origin_type != context_type:
if context_type in PROTECTED_TYPES and not _via_transform:
raise ContextTypeError(
{origin_type.value} cannot be inserted into
{context_type.value} context
(content first registered as {origin_type.value}, id={origin_id})
)
项目中所有其他内容的存在是为了正确设置此检查,并为其提供有意义的比较对象。
合法的类型更改仍然需要一条清晰、有意的路径来发生。一个单独的 transform() 例程允许工具输出显式地成为证据,但仅在通过最低限度的验证检查之后(例如拒绝包含“error”或“failed”等失败标记的字符串)。至关重要的是,它总是附加前面描述的 derived_from 谱系跟踪,而不是原地修改原始对象。
所有这些都不需要模型参与。这一点值得稍作思考,因为很容易读到“上下文类型系统”并假设某个地方有一个分类步骤要求LLM标记每条上下文。事实并非如此。
已经知道一个值来自工具调用的调用者在它进入存储的那一刻就将其声明为 TOOL_OUTPUT。该类型不是通过事后分析文本推断出来的;而是由生成该内容的应用程序部分断言的。这与函数返回类型不是在调用站点猜测,而是在编写函数时声明的方式完全相同。
汇编器是类型化对象最终变成纯文本字符串的唯一地方。它按照固定的、确定性的顺序(首先是指令,然后是记忆、证据和工具输出)遍历存储的项目,并将每个渲染到一个干净标记的部分。该步骤上游的所有内容都严格操作于具有真实、可检查字段的 ContextItem 对象。只有在最后一步,丰富的类型信息才会折叠成一个原始提示。
以下是整个架构如何组合在一起的:
模型在管道的末端仍然看到纯文本标记。那个最终箭头之前的所有内容才是这里真正的新内容:结构化的、类型化的对象,您的应用程序代码可以在内存中分配单个提示字符串之前对其进行检查、验证和拒绝。
捕获的输出:实际发生的情况
我运行了实际的实现,而不是描述它应该做什么。这是 demo.py,执行一次,输出从头到尾捕获,未经编辑:
--- 摄入后的出处账本 --- [instruction ] source=system id=7e4205e4 [memory ] source=conversation_memory id=610e3a2d [tool_output ] source=tool:order_lookup id=d74461e6
三个对象输入。三个类型化对象输出,每个都带有显式来源和ID。还没有什么令人惊讶的,但请注意与纯提示字符串已经不同的地方:运行时现在持有三个可检查的记录,而不是三行可互换的文本。
接下来,演示尝试一个合法的提升(原始工具输出,经过验证,成为证据):
--- 尝试直接将原始工具输出提升为证据 --- PROMOTED: evidence id=cd37dd7f <- d74461e6
那个 <- d74461e6 是 derived_from 字段。它不是装饰性的。它意味着证据项目直接追溯到它来自的工具输出项目,并且即使在类型改变后,该谱系也在对象中幸存。
然后演示尝试整个项目存在的目的:捕获的操作——将相同的工具输出直接插入指令通道,没有转换,仅仅是重新标记。
--- 尝试直接将工具输出提升为指令 --- REJECTED: tool_output cannot be inserted into instruction context (content first registered as tool_output, id=d74461e6)
这里的重要结果很简单:运行时拒绝了一个其类型规则不允许的上下文转换。原始工具输出仍然是一个 tool_output 对象。没有推断、协商或解释。这是 validator.py 中的一个if语句触发了,与任何其他类型检查触发的方式完全相同。
最终组装的提示同时显示证据项目和原始工具输出项目,并排,因为提升不会删除源:
--- 最终组装的提示(工具输出保持为工具输出)--- Instructions: - 使用当前订单信息回答。 Memory: - 客户更喜欢简洁的回答。 Evidence: - 订单 #1842:预计交付日期8月19日。历史记录:客户之前要求8月25日交付。 Tool Output: - 订单 #1842:预计交付日期8月19日。历史记录:客户之前要求8月25日交付。
是的,两次看到该文本看起来浪费。同时包含原始工具输出和提升后的证据会增加您的标记计数,并且该成本不是零。我在这个演示中故意保留了两者,因为它是展示提升创建了一个新对象而不是原地覆盖原始对象的最清晰方式。在生产环境中,您会为提示选择一种表示,并将另一种保留在您的跟踪日志中。
跨转换的出处
运行时为那一个转换保留的谱系看起来像这样:
从完全相同的源对象开始的两次独立尝试产生了两种不同的、可预测的结果。至关重要的是,这两种结果在对象图本身内部都是立即可见的,而不是深埋在需要自己专用审计日志才能追溯的自定义应用程序代码中。
测试套件
通过盯着一个巨大的序列化提示来调试智能体故障是痛苦的,因为所有东西都已被扁平化。类型化上下文为您提供了一些可以直接测试的东西,无需模型参与。我写了八项检查,涵盖类型注册、无效提升、出处保留、记忆和证据的分离,以及失败工具输出的验证。
以下是完整的测试输出:
[PASS] Test 1a: evidence item registered with correct type [PASS] Test 1b: promoting that evidence to instruction is rejected — evidence cannot be inserted into instruction context (content first registered as evidence, id=6e9dc559) [PASS] Test 2a: historical memory item still present, unmodified [PASS] Test 2b: current state promoted to evidence with visible lineage [PASS] Test 2c: memory item and evidence item are distinct, neither overwritten [PASS] Test 3a: direct tool_output -> instruction insertion is rejected — tool_output cannot be inserted into instruction context (content first registered as tool_output, id=3074861e) [PASS] Test 3b: original item remains tool_output, unaffected by the rejected attempt [PASS] Test 4: failed tool output cannot be promoted to evidence — tool output from 'tool:shipping_api' failed validation and cannot become evidence: 'Status: failed. No delivery date available.' 8/8 checks passed
值得明确这个数字确实意味着什么和不意味着什么。通过全部八项检查仅仅意味着实现遵守其自身规则:无效提升被拒绝,合法转换留下审计跟踪,原始对象保持不变。它并不证明类型化上下文使智能体的下游答案更智能,我也不打算假装不是这样。请牢记这个区别以理解后面的所有内容。
这些检查都不需要LLM、模拟API响应或预测模型可能说什么的合成数据集。这正是在上下文层而非提示-响应层测试的全部意义。依赖模拟模型输出来测试应用程序逻辑的测试套件是在测试与您实际构建的东西相邻的东西。这些检查测试的是管道本身。
该工具在自己身上捕获的一个错误
在连接 demo.py 时,我在账本中遇到了一个真正的错误。每次 transform() 在新类型下重新注册内容时,代码会用最新的ID覆盖原始注册ID,而不是保留它。这导致拒绝错误指向错误的项目作为冲突来源。一个 tool_output 对象的错误消息在引用它自己的后续证据变体。
搞错出处使类型系统比无用更糟糕,因为它会给您自信但错误的诊断。修复只是一个简单的保护条件,以锁定它看到的第一个ID:
if existing is None:
# First time this content has been seen — this item becomes
# the permanent origin record for the ledger key.
self._ledger[key] = (context_type, item.request_id)
我想展示这一点,因为假装一切在第一次尝试时就奏效会错失重点。这里的错误是基本的状态跟踪错误,而不是模型怪癖。而这些正是上下文类型系统应该帮助您捕获的确切错误,即使您将它们写入了类型检查器本身。
工厂车间类比
没有车间经理会将装配指南、检查报告和废料零件仅仅因为它们放在同一张长凳上就倒进一个未标记的箱子里。工作指令告诉您如何建造。质量检查显示测量了什么。有缺陷的零件解释了为什么之前的运行失败了。您保持这些项目不同,以便没有人拿起一个被拒绝的零件,以为它属于最终装配。
提示字符串就是那张长凳,上下文项目是您放在上面的东西。
我构建的运行时充当库存标签。它不决定建造什么。它只是阻止坏零件进入指令堆,以免下游某人犯下昂贵的错误。
诚实的设计决策
1. 字符串标准化而非内容哈希
账本键只是一个标准化的字符串。_key() 折叠空白并将文本转为小写。如果两个不同的上下文项目标准化为完全相同的字符串,它们在账本中冲突并共享一个来源记录。这个捷径对于原型来说很好用。在生产环境中,处理大量类似的工具输出需要正确的加密哈希和显式冲突处理,而不是字符串操作。
2. 单一受保护通道
此处只有 INSTRUCTION 受到保护。证据、记忆和工具输出在类别之间移动的限制较少。这是一个有意的范围选择,而非疏忽。此处针对的特定错误类别是外部数据被重新标记为指令,因为指令文本对模型行为施加的控制最大。实际部署可能选择保护额外的通道,如 MEMORY。
3. 临时标识符
ID在每次执行时都会改变。request_id 依赖于未设定种子的 uuid.uuid4()。每次运行 demo.py 或 tests.py 都会产生全新的十六进制字符串,尽管通过/失败结果保持不变。如果您运行代码来验证记录,预期匹配的是结构形状而不是匹配的十六进制字符串。
4. 内存作用域
ContextStore 仅存在于单个请求周期的内存中。它不会在进程重启后存活,并且缺少并发写入的线程锁。该设计适用于单请求原型。扩展到多智能体架构或持久状态需要从一开始就支持可交换存储和线程安全。
5. 硬编码转换规则
类型提升依赖于一个硬编码查找表(ALLOWED_TRANSITIONS)。运行时从不推断或学习一个转换是否看起来合理。这整个模式的核心价值来自于保持转换显式和可审计,而不是让运行时猜测意图。
权衡与缺失的内容
1. 最小类型词汇表
该系统仅提供四种核心类型。它省略了像 TASK_STATE、POLICY 或自定义领域类型等额外类别。在代码中添加新的枚举值很容易,但每个新条目都会迫使您手动在 PROTECTED_TYPES 和 ALLOWED_TRANSITIONS 中定义其处理规则。类型系统无法为您做出这些策略选择。
2. 在模型调用之前停止
此原型以提示组装结束。将 ContextStore 连接到活动智能体循环、工具路由器或向量存储管道的部分被有意省略。将执行引擎与模型执行解耦使您能够隔离测试和验证类型规则。
3. 零自动类型推断
调用 add_context() 的应用程序代码必须预先声明内容类型。系统从不扫描原始文本以猜测一个字符串是否看起来像指令或证据。从文本推断类型是一个独立的、易出错的问题。在此处添加AI分类器会将确定性保证降级为统计猜测。
4. 仅限于单进程运行
ContextStore 完全在本地内存中为单个请求运行。它不包括序列化层、网络传输或用于在分布式工作线程或多智能体网络间共享类型化上下文的同步机制。
5. 无微基准测试
在这里测量执行速度会产生误导。内存中的字典查找和字符串检查耗时微秒,与对LLM的实际网络调用相比,这相当于舍入误差。性能基准测试只有在您引入复杂的模式验证或大型策略集时才变得相关。
诚实的要点
这是一个有针对性的强制执行机制,旨在捕获一个特定的错误类别:内容在进入提示的过程中无声地改变语义角色。它不是一个完整的智能体编排框架。将其扩展以处理分布式状态或动态分类在理论上很直接,但在此代码库中尚未测试。清楚地说出这一点比假装这端到端地解决了上下文管理更重要。
这解决了什么——以及没有解决什么
它提供的是:
它不能保证的是:
第二个列表比乍看起来更重要。在模型上游运行的类型检查器无法修复模型对类型化输入所做的处理。它所能做的是确保上下文在到达提示窗口之前没有被类型混淆错误无声地破坏。
它将一类微妙的运行时错误转变为您可以用单元测试捕获的东西,而不是您通过逐行盯着五千个序列化文本标记来发现的东西。
三个层,而不是一个取代另一个
值得精确地说明这与智能体设计中已经常见的两个术语的关系,主要是因为它很容易将上下文类型误解为现有想法的重新包装。
这些层中没有任何一层取代其他层;它们是堆叠的。一个正确类型化的上下文对象仍然必须被构造成一个清晰的、措辞良好的提示。反过来,一个从错误类型化上下文生成的精心打磨的提示无论文本写得多么好,仍然是不安全的。上下文类型仅仅形成了基础层,在提示和上下文工程接管之前确保数据完整性。
使用这个调试时实际发生了什么变化
在引入类型化上下文之前,调试一个意外的模型响应通常归结为盯着一个单一的、巨大的、扁平化的字符串:
从那里开始故障排除主要是受过教育的猜测。检索步骤有缺陷吗?历史记忆过时了吗?系统提示措辞含糊吗?工具输出返回了误导性数据吗?当任何问题发生时,所有上下文都已被扁平化为可互换的文本,没有留下任何自然边界可以让您隔离问题。
当上下文携带显式类型标签和出处元数据一直到提示组装时,同一项调查获得了离散的检查点:
您不是将每个问题都视为一个模糊的下游故障,而是可以将错误定位到特定的管道阶段。与说“这使智能体更智能”相比,这是一个谦虚的说法,但它是一个现实得多的说法。它缩短了事情出错时的诊断时间,而不会对模型在接收到清晰输入时的推理能力做出虚假承诺。
实际要点
下次智能体产生奇怪响应时,添加另一段系统指令很少是最高杠杆的修复。在重写提示之前,值得退一步问一组更窄的结构性问题:
如果对上述任何一个问题的回答是肯定的,添加更多提示文本仅仅是在治疗症状。真正的修复在下一层,在上下文运行时内部。
在运行时层修复这个问题不如调整提示那么引人注目,您也不会得到那种观察模型在下次运行时改变语调的即时反馈。但它会给您一些更好的东西:一个关于实际出错的清晰信号。您可以立即判断是上下文在进入时被破坏了,还是模型只是在干净数据上推理错误。这些是完全不同的问题,但大多数智能体设置将它们混为一谈,并希望一个提示补丁能同时修复两者。
上下文工程决定哪些信息到达模型窗口。上下文类型决定这些信息在序列化之前被允许意味着什么。对于任何从多个来源获取上下文的智能体来说,这种区别都在做必要的工作,无论您当前的运行时是否显式执行它。
重现此内容
完整项目由六个模块加上一个演示和一个测试文件组成,不需要外部依赖:
python demo.py python tests.py
两个脚本都在毫秒内完成,无需网络调用或API密钥。在循环中没有LLM的情况下运行,使执行快速、本地且可预测。
如果您自己运行代码,request_id 值依赖于 uuid.uuid4(),因此您生成的ID将与此文中的十六进制字符串不匹配。这是预期行为。即使在特定ID变化时,管道结构、拒绝和提升规则在运行间保持不变。比较两次运行的输出显示相同的日志形状和不同的哈希值,表明即使标识符是随机的,底层类型规则也是确定性的。
参考文献
[1] Andrej Karpathy,在X上的帖子,2025年6月25日:将上下文工程描述为“填充上下文窗口的精妙艺术和科学”,为给定步骤提供正确的信息。—https://x.com/karpathy/status/1937902205765607626
[2] Bertrand Meyer,“应用‘按契约设计’”,Computer (IEEE), Vol. 25, No. 10, October 1992, pp. 40–51. —https://dl.acm.org/doi/10.1109/2.161279
[3] Python软件基金会,dataclasses— Data Classes, Python 3文档。—https://docs.python.org/3/library/dataclasses.html
[4] Python软件基金会,enum— Support for enumerations, Python 3文档。—https://docs.python.org/3/library/enum.html
披露
本文中的所有代码均由我编写,是原创作品,在Python 3.12上开发和测试。本文不包括基准数字;显示的终端输出是直接从 demo.py 和 tests.py 的实际运行中捕获的,零API调用,并且可以通过克隆 github.com/Emmimal/context-type-system 的仓库并直接运行这两个脚本来重现。
该实现除了Python标准库外不使用任何库;测试套件是纯Python,而非测试框架。本文中的所有图表,包括题图,均由我创建。题图使用ChatGPT(DALL·E)生成;图表(证据链、架构管道、跨转换的出处谱系、三层比较以及调试前后的流程)直接基于项目自身的代码和设计构建。我与本文中提到的任何工具、库或公司没有财务关系。
