实战建筑智能体

我一直看到同一个错误: 团队解析长文档,将完整的 API 响应交给智能体,然后想知道为什么它会停滞。使解析输出对工程师有用的边界框、置信度分数和坐标数据,正是使智能体的上下文窗口窒息的相同令牌。

最近我反复从构建处理不同文档的智能体的客户那里听到同样的问题。他们通过 API 处理长文档,将 JSON 响应输入到智能体,当整个上下文窗口在智能体能够做任何工作之前就已经满了,就会撞到一堵墙。

我在许多场景中看到过这个问题。

一个审查合同的法律智能体,需要将赔偿条款与责任限额进行交叉引用,而这两者相隔 60 页。 处理索赔的保险智能体,必须将 CMS-1500 表格上的行项目与保单的承保时间表进行匹配。 从 10-K 报表中提取分段数据的金融智能体,这些报表跨越 200 多页,包含嵌套表格和引用先前展品的脚注。

但问题的形状总是相同的:原始解析输出包含的信息远超智能体所需,而这些额外信息正在损害性能。

1、示例:用于建筑工作流的智能体

一个最近的案例特别清楚地说明了这一点。一位构建建筑项目变更订单审查智能体的客户向我们提交了一份 100 页的变更订单,当被解析时产生了 200,000 行 JSON。

工作流: 承包商提交变更订单,智能体将每个订单与原始合同、项目时间表和单价表进行交叉引用,在审查电子表格中标记差异。

由于用户是审查数百万美元索赔的建筑项目经理,每个发现都需要链接回 PDF 中的确切源区域。 不是"第 47 页"。而是围绕行项目的确切边界框。

他们已经使用沙箱环境、电子表格工具和边界框引用构建了整个系统。这是一个非常可靠的架构。

但智能体在较长文档上一直停滞。

他们的第一本能是在其之上添加一些东西: 也许是目录、章节摘要,或者一种给智能体一个压缩视图以便其能够深入了解的方法。

我看了一眼这 200,000 行 JSON 中实际包含的内容,立即看到了一个更好的解决方案。

2、问题:原始 API 响应不是为智能体输入设计的

Reducto 的解析响应是为工程灵活性设计的。 输出中的每个块都获得边界框、OCR 置信度分数、类型分类和坐标数据:

{
  "result": {
    "chunks": [{
      "content": "承包商应提供所有劳务...",
      "embed": "承包商应提供所有劳务和材料...",
      "blocks": [{
        "type": "Text",
        "content": "承包商应提供所有劳务...",
        "bbox": {"left": 0.08, "top": 0.12, "width": 0.84, "height": 0.03, "page": 1},
        "confidence": "high"
      }]
    }]
  }
}

有两点需要注意。

首先,每个块上有两个文本字段: content 是带有 markdown 格式的原始解析文本,而 embed 是一个检索优化版本。对于表格,它包含一个自然语言摘要,可以改善向量搜索分数。对于 RAG 管道,您索引 embed。 对于智能体上下文窗口,您需要 content。与我交谈的大多数团队没有意识到这些是不同的,当您计算令牌时,这种区别很重要。

其次,看看有多少结构包裹着每一块内容。每个块都携带一个具有五个字段的 bbox 对象、一个类型分类、一个置信度分数。 一个具有数千个块的文档重复所有这些数千次。 当我查看建筑变更订单时,边界框数组、置信度分数和类型元数据占据了 JSON 的绝大部分。实际的文档文本、合同条款、单价和范围描述只是总输出的一小部分。

这正是您想要用于文档查看器或布局分析管道的内容。这不是您想要在智能体的上下文窗口中的内容。 智能体试图推理合同条款和单价,同时穿过它无法用于实际任务的坐标数组。

我们为工程师构建 JSON,而不是为了让智能体直接消费。这可能适用于大多数结构化 API 响应,而不仅仅是我们的响应。许多团队跳过了中间的一个预处理步骤。

3、更好的解决方案

在解析输出到达智能体之前,将其分为两种表示形式:

contentmetadata

从 API 响应中提取 .result.chunks[].content 并将其写入 .md 文件。丢弃坐标、置信度分数、块元数据。剩下的是文档的干净、可读版本,比完整的 JSON 小得多。

问题: 许多客户需要块级别的引用。当智能体标记某些内容时,他们的产品会在源 PDF 中高亮显示确切区域。 您不能丢弃边界框,但也不需要它们在上下文窗口中。

解决方法很简单:在 markdown 中为每个块编号。

块 0 | 第 1 节:一般条件
块 1 | 承包商应提供所有劳务、材料和设备...
块 2 | 展品 B 中指定的单价应管理所有定价...
块 3 | 1.1 工作范围
块 4 | 本变更订单涵盖的工作包括对...的修改

完整的解析 JSON(或从中提取的 CSV)存在于智能体的沙箱文件系统中。智能体读取并推理 markdown。当它发现值得引用的内容时,比如块 47 中的单价差异,它编写一个快速 pandas 片段,从结构化文件中提取边界框:

import pandas as pd

metadata = pd.read_csv("change_order_metadata.csv")
block_47 = metadata[metadata["block_id"] == 47]
bbox = block_47[["left", "top", "width", "height", "page"]].iloc[0].to_dict()
# {"left": 0.05, "top": 0.34, "width": 0.9, "height": 0.02, "page": 47}

元数据从未驻留在上下文窗口中。它通过代码按需获取,仅在需要时。

这就是为什么沙箱编码智能体对于文档工作如此强大的原因。

智能体像开发者一样处理结构化元数据: 作为要查询的数据,而不是要阅读的文本。

大约 20 行后处理代码来设置。

4、构建章节索引

对于超过 50 页的文档,我发现章节索引会带来显著差异。

想法: 从解析输出中提取类型为 TitleSectionHeader 的每个块,并构建一个可导航的目录:

index_lines = []
for block_id, block in enumerate(all_blocks):
    if block["type"] in ("Title", "SectionHeader"):
        index_lines.append(f"块 {block_id} | {block['content']}")

with open("section_index.md", "w") as f:
    f.write("\n".join(index_lines))

现在智能体可以首先阅读一个简短的章节索引,找出哪些章节是相关的,然后仅从完整的 markdown 中阅读这些章节。 对于建筑智能体,这将"阅读整个合同"的方法转变为"找到第 4 节:单价和第 7 节:变更订单程序,然后交叉引用。"在速度和准确性方面简直是天壤之别。

5、处理多个文档

建筑工作流不仅仅涉及一个文档。智能体将变更订单与原始合同、时间表和单价表进行交叉引用。至少四个文档。

编号块模式通过在文档名称前缀前缀每个块 ID,可以干净地扩展到多文档场景:

# change_order.md
CO:块 0 | 第 1 节:一般条件
CO:块 1 | 承包商应提供所有劳务...

# original_contract.md
OC:块 0 | 第 1 条:协议
OC:块 1 | 业主和承包商同意...

# unit_rates.md
UR:块 0 | 展品 B:单价表
UR:块 1 | 混凝土(3000 PSI):145 美元/立方码

当智能体发现变更订单在 CO: 块 23 中声称混凝土为 185 美元/立方码时,它可以引用 UR: 块 1 显示的合同单价为 145 美元/立方码。 引用是精确的,交叉引用是明确的,元数据查找以相同的方式工作,只是在块 ID 上带有文档前缀。

每个文档都有自己的元数据 CSV。 智能体首先加载所有文档的章节索引(通常总共几百个令牌),然后深入挖掘它需要的特定章节。

6、给智能体正确的工具

内容/元数据分离只有在智能体具有匹配模式的工具时才有效。许多团队给他们的智能体一个通用的"读取文件"工具,然后认为完成了。 这就像给研究人员访问书籍但没有目录、没有索引、没有搜索。

这是一个对我帮助的团队效果很好的工具架构:

tools = [
    {
        "type": "function",
        "function": {
            "name": "search_document",
            "description": "在文档中搜索与关键字或短语匹配的块。返回匹配的块及其 ID。",
            "parameters": {
                "type": "object",
                "properties": {
                    "doc_prefix": {"type": "string", "description": "文档前缀,例如变更订单的'CO'"},
                    "query": {"type": "string", "description": "要搜索的关键字或短语"},
                    "max_results": {"type": "integer", "default": 10}
                },
                "required": ["doc_prefix", "query"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "read_section",
            "description": "从文档中读取一个范围的块。在搜索后使用以读取周围的上下文。",
            "parameters": {
                "type": "object",
                "properties": {
                    "doc_prefix": {"type": "string"},
                    "start_block": {"type": "integer"},
                    "end_block": {"type": "integer"}
                },
                "required": ["doc_prefix", "start_block", "end_block"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_citation",
            "description": "获取特定块的边界框和页码。当将发现写入审查电子表格时使用。",
            "parameters": {
                "type": "object",
                "properties": {
                    "doc_prefix": {"type": "string"},
                    "block_id": {"type": "integer"}
                },
                "required": ["doc_prefix", "block_id"]
            }
        }
    }
]

三个工具: 搜索、读取、引用。智能体搜索相关章节,按需将它们读取到其上下文中,并仅在需要编写引用时获取边界框。除非智能体明确要求,否则不会加载任何内容。

将此与常见模式进行比较,其中智能体有一个工具,"read_file",它将整个文档转储到对话中。

使用该设置,智能体加载完整的变更订单,填充其上下文,甚至无法再查看原始合同。

7、一个值得修复的提示模式

在许多系统提示词中,都有类似这样的指令:

从头到尾阅读每个附加的文档。

这听起来很合理。您希望智能体是彻底的,但模型非常善于遵循指令,即使这没有意义。 当您告诉智能体从头到尾阅读每个文档时,它会尝试将每个文件加载到其上下文窗口中。

对于 100 页的变更订单加上支持文档,这意味着整个上下文预算在智能体做任何有用的事情之前就消失了。

这个效果好得多:

您可以通过搜索和读取工具访问多个建筑文档。
首先阅读章节索引以了解文档结构。
搜索相关章节而不是加载整个文档。
当您发现差异时,引用两个文档中的特定块 ID。
如果文件非常大,请搜索相关章节而不是加载
整个文档。

差异是鲜明的。第一个提示产生的智能体就像一个在考试前临时抱佛脚的学生。 第二个产生的就像一个研究人员:grep 相关章节,读取有针对性的块,使用工具提取特定数据。

它导航文档而不是试图记忆它们。

这对工具设计也很重要。

如果您的智能体正在填写审查电子表格,一个按关键字或章节标题搜索并返回相关块的工具,比将整个文件转储到对话中的工具有用得多。

8、验证引用

我早期没有意识到的一件事:智能体有时会引用错误的块。它会说"块 47 显示费率差异",而实际的差异在块 49 中。 智能体正在阅读正确的章节,但其块号引用发生了漂移。

一个简单的验证步骤可以捕获这一点:

def verify_citation(doc_prefix, block_id, expected_text_fragment):
    """检查引用的块实际上是否包含智能体认为其包含的内容。"""
    with open(f"{doc_prefix}_document.md") as f:
        lines = f.readlines()

    for line in lines:
        if line.startswith(f"{doc_prefix}:块 {block_id} |"):
            block_text = line.split(" | ", 1)[1]
            return expected_text_fragment.lower() in block_text.lower()
    return False

将其连接到智能体用于将发现写入电子表格的工具中。在它提交发现之前,它验证引用的块实际上包含它声称的内容。

这捕获了漂移问题,并且要么自我纠正,要么将发现标记为人工审查。

在建筑智能体上,这显著减少了错误引用,当项目经理在审查数百万美元变更订单的发现时,这很重要。

9、完整管道的样子

对于文档繁重的智能体,这是有效的内容的形状:

文档上传(变更订单 + 合同 + 时间表 + 费率)
        ↓
Reducto 解析 API(chunk_mode: "block" 用于 1:1 块到边界框映射)
        ↓
预处理(每个文档约 20 行)
  ├── 提取块内容 → 带前缀的编号 .md 文件
  ├── 提取标题 → section_index.md
  ├── 存储元数据 → 沙箱中的 CSV
  └── 对每个文档重复
        ↓
智能体沙箱
  ├── 章节索引 → 智能体首先阅读这些
  ├── .md 文件 → 智能体通过工具读取有针对性的章节
  ├── 元数据 CSV → 智能体使用代码查询引用
  └── 工具:search_document、read_section、get_citation
        ↓
智能体发现差异:CO:块 23 声称 185 美元/立方码 vs UR:块 1 为 145 美元/立方码
        ↓
verify_citation() 确认两个块引用
        ↓
智能体使用来自两个文档的 bbox 引用将发现写入电子表格
        ↓
应用层在源 PDF 中呈现引用高亮

关于 chunk_mode 的说明:对于这种模式使用 "block"

块模式意味着解析输出中的每个块 1:1 映射到具有自己边界框的单个布局元素。 变量模式(默认值,更适合 RAG)将块合并为语义连贯的块。 非常适合向量搜索,但它破坏了使引用模式工作的干净块 ID 到边界框映射。

10、何时使用不同的方法

这种将内容与元数据分离的模式并不是一切的正确答案。

我是这样思考的:

使用向量搜索的分块 在智能体不需要进行详细交叉引用时更好。

如果您正在构建一个"询问有关此文档的问题"聊天机器人,用户一次问一个问题,您不需要在任何上下文窗口中拥有完整的文档。 嵌入块,检索 top-k,让模型从这些块中回答。

Reducto 的 variable 块模式和 embed 字段是专门为此构建的。embed 中的表格摘要显著改善了具有密集表格的文档的检索分数。

分层摘要 在您需要智能体在深入了解之前理解大局时有效。

想想来自 200 页备案的高管简报。摘要每个章节,给智能体摘要,让它决定阅读哪些章节的全部内容。缺点:您失去了块级别的引用轨迹,因为摘要是生成的文本,而不是原始文档内容。 在每项发现都必须追溯到源区域的受监管行业中,这是一个决定性的因素。

基于架构的提取(Reducto 的提取端点)是当您提前确切知道需要哪些字段时的正确选择。

如果任务是"从每个合同中提取有效日期、总金额和对手方",不要给智能体一个文档和一个提示词。 定义一个类型架构,获得具有字段级边界框的结构化 JSON,并完全跳过智能体进行提取步骤。

我之前提到的保险索赔智能体最终移动到这种方法,用于 CMS-1500 表格上的结构化字段,并仅在提取架构无法到达的自由文本叙述部分使用内容/元数据分离。

使用沙箱工具的内容/元数据分离 是当智能体需要在多个长文档上进行开放性推理并具有精确引用时我会采用的方法:一种您无法预先定义每个字段的工作,交叉引用是不可预测的,并且输出需要可追溯到确切源区域。

合同、索赔、财务尽职调查、监管审查。

11、一般原则

用更大的上下文窗口解决长文档问题很诱人。 但一个主要包含坐标元数据的百万令牌上下文,比一个带有干净 markdown 和良好工具的较小上下文更糟。

上下文不是免费的。每个令牌影响模型行为,对任务没有用的令牌主动挤占了那些有用的令牌。

建筑智能体从难以处理单个 100 页的变更订单,变成了交叉引用四个文档还有剩余空间。不是因为我们给了它更多令牌,而是因为我们在将哪些令牌放入窗口方面更加明智。

我们的 API 是为最大保真度设计的。这是在其之上构建的工程师的正确默认值。但是,API 返回的所有内容与智能体实际需要的内容之间存在差距。使用一些预处理、章节索引和正确工具弥合这一差距的团队看到其智能体显著更好的准确性和性能。


原文链接: Stop feeding raw parse output to your agents: split content from metadata first

汇智网翻译整理,转载请标明出处