亚马逊AWS官方博客

从 LLM OCR 到分布式 Agent:一个报销工具的三次进化

摘要:以发票报销自动化工具的三次演进为案例,从 LLM OCR、Agent 到 Skill + MCP,剖析 AI 应用形态的演进,以及 AI Agent 调用外部系统时的超时、状态污染与幂等等分布式系统问题及工程应对。


一、引言

你的 AI Agent 调用报销接口,网络超时了——那这笔报销到底提交成功了没有?

TikTok SRE 技术负责人 Salman Munaf 在一场演讲中系统阐述了一个观点:当 AI Agent 开始调用外部服务时,它就不再只是一个模型问题,而变成了分布式系统问题。它会遇到这个领域几十年来已经反复研究和命名的各种故障模式——网络超时、状态不一致、重复请求、级联故障。

这不是一个理论问题。过去一年,我在开发一个发票报销自动化工具的过程中,经历了从”LLM 只做 OCR”到”Agent 全自动执行报销流程”的三次范式转变。每一次转变都带来了新的能力,也暴露了新的系统性风险。这篇文章,就是用一个真实的工程案例,来验证 Salman 的分布式系统框架。

二、背景:报销,一个典型的自动化场景

对于中国的企业员工来说,报销是一个高频、低效、且令人痛苦的流程。一次出差可能产生十几张发票——网约车电子发票和行程单需要配对、酒店发票和水单需要匹配、餐饮发票需要按早中晚分类、话费发票需要按职级比例拆分。这些规则散落在公司政策文档里,每个月花费数小时手工录入到 Concur/Emburse 等报销系统。

这个场景有几个特点使它成为 AI 自动化的理想试验田:

  • 输入是非结构化的(PDF 发票),需要 LLM 理解和提取
  • 规则是复杂的(分类、匹配、拆分、城市映射),需要领域知识
  • 有改变系统状态和数据的输出(创建报销条目、上传收据、提交审批),需要与外部系统交互
  • 错误的代价是真实的(重复报销、金额错误、收据丢失)

下面,我将按时间线讲述这个工具的三次进化,以及每次进化带来的范式转变。

三、第一阶段:Plugin —— LLM 作为纯函数嵌入到插件中

3.1 形态

第一个版本是一个 Tampermonkey 插件,嵌入在 Concur 报销页面中。它的核心架构是:

用户按规则整理文件夹 → 上传文件 → Lambda 调用 Bedrock LLM OCR → 结构化 JSON → 前端填充 GraphQL Mutation → Concur 创建 Expense

3.2 LLM 的角色:一个接收 PDF、输出 JSON 的纯函数

在这个阶段,LLM 的作用非常明确:给它一张发票 PDF,它返回结构化的字段——日期、金额、商户名称、发票号码。不同类型的发票有不同的提取 Prompt(每种类型的票据都要定制 prompt,再多轮对比测试,耗时的重体力活!):

  • 餐饮发票:提取金额、商户、税号(从 18 位统一社会信用代码的第 3-6 位推断城市)
  • 网约车发票:提取金额、日期、服务类型(用于区分发票和行程单)
  • 酒店发票/水单:提取房间号、入住/退房日期、每日房价(用于匹配和拆分)
  • 话费发票:提取月份、金额、运营商

LLM 没有任何副作用。它唯一能产生的影响就是一个错误的输出——比如把金额识别错了。但这个错误是封闭的,不会往外扩散,因为最终的 Expense 创建是由前端代码通过 Concur GraphQL API 完成的。

// plugin/src/concur/api/api.tsx — 直接调用 Concur GraphQL
export function graphql(data: any) {
  return axios({
    method: "post",
    url: "https://you.reimburse.tool.com/spend-graphql/graphql",
    withCredentials: true,  // 使用浏览器 Cookie 认证
    data: JSON.stringify(data),
  })
}

3.3 人是分类器

这个阶段的一个显著特点是:发票分类是人做的,不是 AI 做的。用户需要按照预定义的规则整理文件夹,相当耗时的低价值重复性工作:

├── 25H1 网约车发票文件夹(发票+行程单放在一起)/
├── 25H1 过路费发票文件夹/
├── 25H1 餐饮发票文件夹/
├── 25H1 酒店发票文件夹(每次住宿一个子文件夹)/
│   ├── 第1次住宿/
│   │   ├── 酒店发票.pdf
│   │   └── 酒店水单.pdf
└── 25H1 话费发票文件夹/

每个 Tab 处理一种类型,流程是确定性的。错误处理也是确定性的——withRetry() 函数对 5xx 和超时重试,对 400/401/403/404 不重试,逻辑清晰,没有歧义。

3.4 小结

维度 第一阶段
LLM 角色 纯函数(PDF → JSON)
写操作 无(LLM 不执行任何外部操作)
分类/编排 人工(按文件夹分类)
错误影响 封闭(错误不会扩散到 LLM 之外)
错误处理 确定性重试(代码预定义)

用 Salman 的话说:“最初 LLM 很简单,文本进、文本出,不执行任何动作。系统是封闭的,模型错了就错在回答里,不会往外扩散。”

这个阶段解决了用户手动填写报销条目的痛苦:手动填报录入是重复性的工作,非常耗时,往往还需要反复校验信息是否准确,易出错被财务打回。但发票分类仍然是手动操作。同时,API 调用是固化的逻辑,修改需要在报销条目创建完成后,在报销平台里手动调整。

四、第二阶段:Extension + Agent —— Agent 开始主导整个流程

4.1 形态

第二个版本将浏览器插件升级为 Plasmo Chrome Extension,并引入了一个核心变化:AI Agent。Agent 能自主完成发票分类,同时引入 Human-in-the-loop 环节,支持手动或以自然语言的方式在创建报销条目之前修改字段,比如将所有话费报销比例统一设置为 75%。

方案选择 Strands Agents SDK 作为 Agent 框架,部署在 Amazon Bedrock AgentCore Runtime。流程变为:

用户批量上传所有票据到 S3(不需要提前分类) → Agent 自动 OCR + 分类 + 匹配 + 合并 → 生成可编辑表单 → 用户确认/修改(手动或以自然语言与 Agent 对话) → Agent 调用前端 Tool 创建 Expense

[图 1]

4.2 关键转变:从纯函数到 Agent Loop 编排

在第一阶段,LLM 只做 OCR。但在第二阶段,Agent 获得了三个 Tool:

Tool 运行位置 有写操作? 功能
processInvoices 后端 (AgentCore) OCR 提取、分类、匹配、PDF 合并
set_form_field 后端→前端 ⚠️ 修改表单状态 设置单个表单字段值
createExpenses 前端浏览器 ✅ 创建 Concur 条目 批量创建 Expense

createExpenses 是一个前端 Tool——它注册在 Agent 的 Tool 列表中,但实际执行发生在用户的浏览器里(因为需要 Concur 页面的 Session Cookie)。这意味着 Agent 有了真实会发生的写操作,能够改变现实系统的状态和数据:它不再只是输出文本,而是真的在 Concur 系统中创建了费用条目。这个过程有出错的风险。但在这个阶段,风险是受控的:

  • Expense 创建发生在用户浏览器中,需要 Concur Session
  • Agent 不能绕过浏览器直接操作 API,中间设计了 Human-in-the-loop 节点审核和修改,最终创建的 Expense 会出现在浏览器页面,方便校验
  • 用户始终在场,可以在确认前修改表单

4.3 Agent 做分类器:解放用户

第一阶段最大的痛点——用户需要手动整理文件夹——在第二阶段被 Agent 接管了。processInvoices Tool 内部集成了完整的分类引擎:

# backend/agent/utils/classify.py — 分类优先级
# 1. docType 特殊文档类型检测(行程单、水单)
# 2. invoiceCode 12 位数字 → 出租车
# 3. serviceType 服务类型关键词(餐饮服务、运输服务、住宿服务)
# 4. 酒店水单:3-choose-2 规则(roomNumber, checkInDate, checkOutDate 有 2 个即判定)

同时,匹配引擎自动完成了原本需要人工整理的工作——网约车发票和行程单按金额+日期匹配,酒店发票和水单按品牌+日期匹配。匹配的票据之后会调用 pypdf 库完成 PDF 合并。

4.4 HITL:自然语言交互

[图 2]

工作流中有意了加入了 HITL 节点,有了 Agent Loop,用户可以手动修改或者使用自然语言与 Agent 对话修改信息:

“把第 3 条报销的城市改成武汉”

“所有话费按 75% 报销”

“第 3 条是多月话费,发票包括了 5/6/7 三个月”

Agent 通过 set_form_field Tool 修改前端表单状态,每轮对话自动注入最新的 formState

[图 3]

4.5 小结

维度 第一阶段 第二阶段
LLM 角色 纯函数 Agent Loop Driver
写操作 有(前端创建 Expense)
分类/编排 人工 Agent 自动
错误影响 封闭 受控(用户浏览器兜底)
错误处理 确定性重试 Agent 重试 + HITL 兜底
用户角色 分类器 + 操作员 审核者

这个阶段是刚进入 Agent 时代的产物。Agent 有了工具,可以直接根据用户的要求灵活调用,能力边界得到极大扩展。风险是可能会犯错,但此时被浏览器 Session 和人工确认双重约束,仍然“可控”。

这个阶段完美的解决了第一阶段仍然痛苦的手动发票分类,并支持自然语言方便灵活的修改字段。但这套 Agent 需要用户手动提前在自己的 AWS 账号部署才能使用,不然只能依赖开发者的 Demo 环境。在遇到 Bug 或有新功能需求时,需要反馈给开发者。同时 Agent 没有添加记忆功能(有意没有添加),不能提供个性化的服务。进入 2026,新的范式彻底解决了这些问题。

五、第三阶段:Skill + MCP —— Agent 即分布式系统的时代

5.1 范式转变的背景

2025 年末,从 OpenClaw 开始,个人 Agent 助手产品进入规模化应用——Amazon Quick、Claude Code、Codex 等工具开始被日常使用。一个关键认知浮现:Agent Loop 不需要自己搭建了,这些产品本身就是功能完备的 Agent Runtime。特定的场景能力,可以提炼成 Skill(可复用的工作流描述)和 MCP(标准化的工具接口)。本地运行的个人 Agent 助手通过配置报销 SKill 和 MCP,不再需要单独的部署,也无需上传发票, Agent 助手带记忆功能,提供更个性化的服务。

[图 4]

这带来了三个维度的范式变化。

5.2 开发范式的变化:Agent 既是产品,也是开发工具

在第二阶段,我需要逆向工程 Concur 的 GraphQL API——手动在浏览器 DevTools 里抓包、分析请求/响应、整理字段映射,把这些 API 整合成 Agent 可用的工具。这是一个耗时且容易遗漏的过程,实际上,第二阶段我只整合了必须的几个 API,没有导入所有 Concur 支持的 API 能力。

到了第三阶段,Agent 本身成为了开发工具,开始承接 Human 原来的工作界面。我使用 Amazon Quick 内置的浏览器自动化能力,让它监控我手动创建报销 Report 和 Expense 的完整过程:

一次操作就能提取出:

  • 所有涉及的 API 接口和参数
  • 哪些字段可以从系统接口获取(如 policyId、expenseTypeId)
  • 哪些字段需要用户输入(如金额、日期、商户名)
  • 系统定义的模板和枚举值(如 expense type 列表)

[图 5]

这些提取出的信息,通过与 Concur MCP 和 Emburse MCP 作者的沟通,最终成为了 MCP 中的两个重要 Tool。开发效率提升了一个量级——原本需要几天的逆向工程,现在一次对话就能完成。

5.3 使用范式的变化:使用者也是开发者

传统软件的生命周期是线性的:开发 → 发布 → 使用 → 报 Bug → 等修复。但在 Skill + MCP 的模式下,这个边界变得模糊了:

① 社区驱动的快速迭代

使用者在日常使用中遇到的 bad case,可以由他们自己的 Agent 来解决。因为 Agent 有完整的执行记录——每一步看到了什么、做了什么、为什么失败了——迭代的反馈循环变得极短:

使用者遇到问题 → Agent 执行记录完整还原现场 → 
定位根因 → 修改 Skill/MCP 或直接调 API → 验证 → 
通过 Slack Channel 反馈给开发者

通过成立社区群,一个工具可以在极短时间内快速成熟。开发者的职责,除了开发外,越来越多地承担社区维护的角色。

② Skill 级的个性化定制

软件不再只在开发阶段有变化。使用者可以根据自己的场景,定制 Skill 中的工作流程。比如:

  • A 用户倾向按一次差旅来报销费用,他可以在 Agent 中添加自己常驻城市,在 Skill 中添加识别一次差旅的步骤,拆分票据
  • B 用户习惯将一段时间的 12306、住宿和餐饮分开报销,他可以调整 Skill 实现按类别分别创建 report/expense
  • C 用户的话费报销比例要求是 75%(而非默认的 100%),他可以固化这个偏好

Skill 越来越适应使用者自身的习惯,Agent 不断积累记忆,功能持续演进。变成了一个”越用越好用”的飞轮。

5.4 错误处理范式的变化:Agent 就是分布式系统

第三阶段还有一个根本性的变化:Agent 不再有浏览器 Session 兜底。在第二阶段,Expense 创建发生在用户浏览器中;但在第三阶段,Agent 通过 MCP 直接调用 Concur/Emburse 的 REST API——这意味着 Agent 独立与外部系统交互,变成了一个真正的分布式系统。

以下是我在实战中遇到的问题,以及它们如何与 Salman 的分布式系统框架一一对应。

(1)问题一:幻觉 reportId —— 上下文即状态,状态会污染

“当上下文能够影响行动时,它就是状态。而状态会变成过期的,状态会和权威数据冲突,状态会污染 Agent 后续的行动。” —— Salman Munaf

事故现场:Agent 调用 create_expense_report 创建报销报告,MCP 返回了 reportId。但在后续的 add_expense 调用中,Agent 没有从返回值中读取 reportId,而是从对话上下文中”回忆”了一个前面查询过的其它 ID。这个幻觉出来的 ID (5Dxxxxxx) 导致所有后续操作静默失败。

根因:Agent 的上下文窗口中混杂了多次工具调用的返回值,LLM 在长上下文中发生了”张冠李戴”,把一个不相关的字符串当作了 reportId。这个记忆没有来源标注,所有的 ID 看起来都一样。

修复 —— 状态持久化

# 创建报告后,立即写入 state 文件
state = {
    "reportId": result["reportId"],  # ← 只从 API 返回值取
    "created_at": datetime.now().isoformat(),
    "expenses": {}
}
json.dump(state, open(f"{WORKSPACE_DIR}/tmp/expense_state.json", "w"))

# 后续所有步骤,只从文件读取 reportId,永远不从对话上下文取
state = json.load(open(f"{WORKSPACE_DIR}/tmp/expense_state.json"))
report_id = state["reportId"]

对应原则:这正是 Salman 所说的——上下文能影响行动时,它就是状态。而 Agent 的对话上下文是一个没有来源标注、没有失效机制的状态。解法是把关键状态从”Agent 的记忆”转移到”文件系统的持久化存储”,让它不可被幻觉覆盖。

(2)问题二:沙箱超时后重复创建 —— 超时 ≠ 失败,超时 = 未知

“超时不代表失败,超时代表未知。” —— Salman Munaf

事故现场:Agent 通过批量调用 add_expense 创建费用条目。执行到一半时,工具调用超时了,Agent 拿到的是一个超时错误。但实际上,MCP 调用在服务端已经成功了——费用条目已经被创建。Agent 看到错误,判断”什么都没创建成功”,于是重新创建了所有 expense。结果:每张发票出现了两个条目,触发了 blocking 告警。

根因:这是分布式系统中最经典的问题——客户端超时不等于服务端失败。MCP 调用跨越了网络边界,客户端(Agent 沙箱)和服务端(Concur API)的状态可能不一致。Agent 的默认行为是”看到错误就重试”,但它对服务端系统的真实状态一无所知。

修复 —— 幂等键 + 先查后做

add_expense 超时 → 
  1. 不盲目重试
  2. 调 get_expense_report 查看当前报告里已有的费用
  3. 按 (amount, date, typeId, vendor) 匹配
     → 已存在:记录 expenseId 到 state,跳过
     → 不存在:安全重试(max 1 次)
  4. 连续 3 次超时 → 停止批处理,保存 state,通知用户

同时,中国发票有一个天然的幂等键:发票号码(invoiceNo)。每次 add_expense 成功后,立即写入 {invoiceNoexpenseId} 到 state 文件。下次执行前先检查——已创建的直接跳过。

# 批处理前:加载已有 state,跳过已创建的
state = json.load(open(f"{WORKSPACE_DIR}/tmp/expense_state.json"))
existing = state.get("expenses", {})

for group in groups:
    invoice_no = group["ocr_primary"]["invoiceNo"]
    if invoice_no in existing:
        print(f"跳过已创建: {invoice_no} → {existing[invoice_no]}")
        continue
    # ... 调用 add_expense ...

对应原则:Salman 说 “AI Agent 面对失败的第一反应就是重试。你必须把幂等性焊死在工具层里。” 我们的做法是双保险——工具层的幂等键(invoiceNo)+ 流程层的先查后做(get_expense_report)。

(3)问题三:两张火车票同一 PDF —— 记忆不可靠,数据必须有来源

我们应该把记忆当作缓存来处理——它应该可以被失效,它应该带着来源信息。” —— Salman Munaf

事故现场:同一天有两趟高铁(往返),商户都是”中国铁路”,金额相同。Agent 在 run_python 中构建文件路径时,没有从源数据文件中读取 primary_pdf 字段,而是根据对话上下文中的”记忆”——日期、商户名、金额——推测了文件名。结果两张不同的发票被认为是重复发票。

根因:Agent 把对话上下文当成了数据源。但同日 + 同商户 + 同金额 ≠ 同文件。文件用 (1) 后缀保证了文件名唯一,而 Agent 的”记忆”丢失了这个关键信息。

修复 —— 数据来源强制约束

约束 2:所有 MCP 工具参数必须来自源文件 json.load() 派生链。
        禁止 Agent 在代码里现场手打字符串常量。
约束 3:文件路径必须来自 groups[].primary_pdf / paired_pdf 字段。
        禁止按日期/商户/金额推测、拼接或"回忆"文件名。
约束 4:一个 group = 一个独立 PDF。
        同日期 + 同商户 + 甚至同金额 ≠ 同文件。
约束 5:run_python 代码中的数据必须来自 json.load(),
        不是来自 Agent 对对话上下文的记忆。

对应原则:“我们应该把记忆当作缓存来处理——它应该可以被失效,它应该带着来源信息。” 在这个场景里,我们的解法更直接:禁止 Agent 使用”记忆”作为数据源。

(4)问题四:Expense ID 漂移 —— 缓存必须可失效

事故现场:Step 4 创建完所有 Expense 后,记录了 {invoiceNo → expenseId} 的映射。但到 Step 5 上传收据时,发现部分 Expense ID 已经变了——Concur 内部的对账机制会在某些条件下重新分配 ID。结果收据被上传到了错误的 Expense 上。

修复 —— 强制刷新

# Step 5 开始前,强制重新查询最新状态
report = get_expense_report(reportId=report_id)

# 用 (amount, date, typeId) 重新映射,不信任上一步缓存的 ID
for expense in report["expenses"]:
    key = (expense["amount"], expense["date"], expense["typeId"])
    fresh_mapping[key] = expense["expenseId"]

对应原则:这正是 “记忆可以被失效” 的实现——每个关键步骤开头,都要从权威数据源(API)刷新状态,不信任本地缓存。

端到端恢复机制

综合以上四个问题,我们的 Skill 实现了一个任意点中断可恢复的状态机:

expense_state.json            
{invoiceNo → expenseId,幂等 + 持久化}       

receipt_state.json
{expenseId → [imageIds],记忆失效 + 权威数据源刷新}

Step 4: add_expense
  ├─ 成功 → 写 state → 继续
  ├─ 超时 → get_report 匹配 → 有: 写 state / 无: 安全重试
  └─ 校验错误 → 修复后重试(非重复风险)

Step 5: upload + attach receipt
  ├─ 上传前: 检查 receipt_state → 已附? 跳过
  ├─ upload 超时 → 安全重传(孤儿 image 无害)
  ├─ attach 超时 → 检查 receiptImageId → 已附? 写 state 跳过
  └─ 成功 → 写 state → 继续

5.5 Salman Munaf 观点与实践对照

# Salman 的分布式系统观点 我们的实践
1 超时 ≠ 失败,超时 = 未知 超时后先查状态,不盲目重试
2 幂等键防止重复副作用 invoiceNo 作为天然幂等键
3 状态查询(status lookup) 每个关键步骤开头做 pre-flight check
4 上下文即状态,状态会污染 禁止从对话记忆取数据,只从 json.load()
5 记忆当缓存:可失效、带来源 Expense ID 漂移 → 强制刷新
6 每一步必须持久化 expense_state.json + receipt_state.json
7 多步失败需补偿操作 Draft 报告天然可重入,偏幂等恢复
8 重试风暴防护 max 5 并行,批处理控制扇出
9 权限最小化 最后的提交必须用户确认(user-gated),Quick 默认“写”动作需要用户审批
10 可观测性:追踪每步决策 State 文件记录映射链

六、三个阶段的对比

维度 Plugin Extension + Agent Skill + MCP
LLM 角色 纯函数(PDF → JSON) Agent Loop Driver 通用 Agent Driver
“写操作” 有(前端工具,受控) 有(直接调 MCP,无浏览器兜底)
分类/编排 人工(文件夹结构) Agent 驱动 Tool 自动分类 + 固化提示词编排 Agent 驱动 MCP 自动分类 + Skill 编排流程
错误影响范围 封闭(错在输出里) 受控(用户在场) 开放(可能影响外部系统)
错误处理 确定性重试 Agent 重试 + HITL 幂等键 + 状态持久化 + 先查后做
用户角色 分类器 + 操作员 审核者 观察者(甚至不在场)
开发者 全部由作者开发 全部由作者开发 社区共建,使用者定制 Skill
确定性 100% 确定性 半确定性(Agent + HITL) 概率性 + 分布式系统约束

七、确定性的消退与工程约束的升级

回顾三个阶段,有一条线贯穿始终:

第一阶段:100% 确定性。每一步做什么、出错了怎么办,都是代码预定义的。LLM 是一个黑盒函数,但它的输出被确定性的代码包裹着。

第二阶段:Agent Loop 引入了概率性——Agent 可能选择不同的 Tool、用不同的参数、以不同的顺序执行。但 HITL(人工确认环节)是一个硬性兜底,用户在提交前可以看到并修改所有内容。

第三阶段:全自动执行,概率性 + 分布式 = 必须用工程手段约束,这正是 Harness 的体现。当用户不在场时,你不能指望”让用户确认一下”来兜底。你需要的是:

  • 幂等键(让重复执行是安全的)
  • 状态持久化(让中断是可恢复的)
  • 先查后做(让超时是可处理的)
  • 数据来源约束(让”幻觉”是不可能的)

用 Salman 的话做结尾:

“在构建 AI Agent 时,最该问的一个问题其实是:当它犯错时,系统允许它做到哪个程度?”

在第一阶段,答案是”错在 OCR 输出里,无所谓,或者使用代码校验”。在第二阶段,答案是”错到表单里,用户能看到”。在第三阶段,答案变成了”可能错到 Concur/Emburse 的生产系统里”——所以我们必须用分布式系统的工具箱来约束它。

模型能力很重要,更聪明的模型能减少错误。但模型能力不能消除网络失败、不能消除过期数据、不能消除状态不一致。 当你的 Agent 开始调用外部服务的那一刻,它就是一个分布式系统。你需要用对待分布式系统的严肃态度来对待它。

本文中引用的 TikTok SRE 演讲观点来自 InfoQ 编译文章《TikTok SRE 技术负责人:AI Agents 说到底就是分布式系统》,原演讲视频:YouTube

➡️ 下一步行动:

相关产品:

  • Amazon Quick — 人工智能助手,用于研究、业务洞察、自动化和无代码应用程序构建
  • Amazon Bedrock — 用于构建生成式人工智能应用程序和代理的端到端平台
  • Amazon Bedrock AgentCore — 加快代理投入生产的速度
  • Amazon S3 — 适用于 AI、分析和存档的几乎无限的安全对象存储
  • AWS Lambda — 无需服务器即可运行代码

相关文章:

*前述特定亚马逊云科技生成式人工智能相关的服务目前在亚马逊云科技海外区域可用。亚马逊云科技中国区域相关云服务由西云数据和光环新网运营,具体信息以中国区域官网为准。

本篇作者

彭金冬

亚马逊云科技解决方案架构师,负责电商行业客户的架构设计和技术支持,致力于帮助客户实现创新、提升效率。


AWS 架构师中心:云端创新的引领者

探索 AWS 架构师中心,获取经实战验证的最佳实践与架构指南,助您高效构建安全、可靠的云上应用