☰
智能体报完工却不落库?状态机+证据链的完工校验实践
2026/11/3 5:10:54 网站建设 项目流程

凌晨两点十七分,某团队的智能体流水线跑完了当天的第 86 个任务,日报里清一色的绿勾,看着让人踏实。第二天早上运营同学打开表格画布,准备核对交付清单,结果业务库里对应的记录一条都没有——智能体报完工,数据库不认账。这不是什么罕见故障,恰恰相反,只要你的智能体工作流里存在「工具返回成功 = 任务完成」这种默认假设,这个问题迟早会找上你。我把这次的排查过程、26H2 管理模板的设计思路,以及怎么用表格画布把整个状态机摆到明面上,完整记一遍。写这篇东西的动机很简单:这类错位在演示环境里几乎不会出现,一上真实数据量就开始冒头,而大部分人第一次撞见时,会先怀疑数据库,再怀疑网络,最后才想起来去看看智能体到底把什么当成了「完成」。本文覆盖智能体任务的完工判定、数据库校验、管理模板字段设计、表格画布看板落地这几块,适合正在把智能体工作流从 Demo 推向生产环境的工程师、运维和数据同学参考,不要求你对智能体框架有多深的理解,但需要你至少跑过一次带写库操作的自动化任务。

1. 「完成」这两个字,到底谁说了算

1.1 一次批量任务里的诡异状态

先把现场还原清楚。那天的任务是批量补录历史订单的归档标记,一共 86 条,分 12 批执行。智能体的执行逻辑很朴素:拿到任务列表,逐条调用一个内部工具,工具内部做三件事——更新订单状态、写一条归档流水、返回处理结果。每一批执行完之后,智能体会汇总本批次的结果,写进日报的「完成情况」字段。

跑完的时候,日报显示 86 条全部成功,零失败。这个数字让人放松警惕,因为失败率是 0,人的第一反应是去看别的地方。等到运营同学按归档日期去业务库捞数据,发现只有 61 条能查到,剩下 25 条查无此人。有意思的是,那 25 条在归档流水表里是有记录的,状态字段也确实是「已归档」,但主表上的标记没变。

这个现象本身就把问题范围缩小了一大半:流水表有、主表没有,说明工具确实被执行了,但主表和流水表的写入不在同一个事务里,或者事务提交了但主表那次更新被静默跳过了。智能体拿到的返回值是工具层的「处理完成」,它没有能力、也没有义务去验证主表到底有没有变。这就是「报完工」和「数据认账」之间那道缝。

1.2 自述式完成与事实式完成的四种错位

我把这几年遇到的同类问题归了归类,基本上跑不出下面四种错位。理解这四类的区别,比记住任何一个具体 bug 都有用,因为它们是结构性的,换个项目照样会撞上。

错位类型智能体看到的现象数据库的真实状态典型诱因
异常吞噬型工具返回 success事务已回滚工具内部 catch 住异常只打日志,返回值仍是成功
目标错位型写入影响行数大于 0写进了影子库或测试 schema连接串取自环境变量,部署时没覆盖
幂等跳过型冲突已处理,无异常记录被 ON CONFLICT DO NOTHING 静默丢弃幂等键设计错误,把不同任务算成同一个
可见性延迟型查询到旧快照新数据在主库已提交但副本未同步读写分离,读走从库

异常吞噬型最常见,也最阴险。很多人写工具体的时候习惯于「不要让异常打断流程」,于是在最外层套一个 try,catch 里记一行日志,然后正常返回。对流水线来说这确实更「稳」,但它把「失败」这个最重要的信号给吃掉了。智能体看到的是成功,它理所当然地报完工。

目标错位型多发于多环境并存的团队。开发、预发、生产三套连接串,部署脚本漏了一个变量,任务跑得飞快,全跑在预发库上,生产库干干净净。这类问题排查起来最耗时间,因为所有日志都正常,所有返回值都正确,唯独查的地方不对。

幂等跳过型和第四种可见性延迟型,后面会单独展开,它们各有各的坑。

1.3 为什么这个问题在智能体工作流里被放大了

传统脚本里也有同样的错位,但影响面小得多,因为脚本是人写的,人知道自己 catch 了什么。智能体不一样,它把「工具返回」当成了唯一的事实来源,而工具返回本身是一个被设计过的、有立场的值。这中间隔了一层语义翻译,翻译过程一旦失真,智能体就会非常自信地报告一个错误结论。

更麻烦的是规模。人跑脚本一次处理十条,出错了肉眼能看出来;智能体一次处理几百上千条,还得靠它自己汇总,汇总逻辑本身也可能是错的。我见过一个更极端的例子:智能体在工具调用超时之后,自己「推断」了一个结果写进日报,因为提示词里写了「尽最大努力完成任务」。这句话对人是激励,对模型是授权它编造。所以只要你的工作流里存在这种开放式指令,完工数据的可信度就得打个折扣。

2. 26H2 管理模板:把「我说做完了」翻译成「系统可验证的做完」

2.1 任务状态机的六态设计与其取舍

26H2 这套管理模板的核心不是流程,是状态。模板本身只是一堆字段和约束,真正起作用的是它强制每个任务必须走完一条明确的状态链,不允许从「执行中」直接跳到「关闭」。我最终定下来的状态是六个,加上两个终态共八个:

  • draft:任务已创建,参数未校验
  • queued:参数校验通过,等待执行
  • running:工具已开始调用
  • pending_verify:工具返回成功,等待数据侧校验
  • verified:校验断言全部通过
  • closed:人工或定时任务确认归档

外加failed和rolled_back两个终态。关键在pending_verify这个状态,它是我从「running 直接到 closed」这条捷径上硬生生切出来的一段。任何任务想关闭,必须先经过数据侧校验,校验不通过就停在 pending_verify,最多挂 30 分钟,超时自动转 failed 并触发告警。

这个设计的代价是流程变长了,一个任务从创建到关闭平均多花两分钟。收益是「报完工但没落库」这种情况从概率事件变成了不可能事件——因为它压根到不了 closed。我算过一笔账,之前那类问题平均每个月要花 3 到 4 个人天去排查和补数据,现在多花的两分钟分摊到每条任务上,成本远低于此。

为什么不干脆用五态?我试过。把 pending_verify 和 running 合并,靠一个布尔字段区分「已校验」,结果是团队里没人看那个布尔字段,等于没做校验。状态机的好处就在于它是有形状的,少一个状态,人的脑子里就少一根弦。

2.2 完工证据链:三元组缺一不可

状态是骨架,证据是血肉。模板里为每个任务强制要求三个绑定字段,我称之为证据三元组:

  1. trace_id:工具调用链路 ID,能反查到具体是哪一次调用、耗时多少、参数是什么
  2. assertion:验收断言,一段可执行的校验描述,比如「按 order_id 查主表,archive_flag 必须等于 1」
  3. affected_rows:工具实际影响的行数,由工具返回,不允许由智能体推算

这三个字段里,assertion是最容易被砍掉的。它的写法确实麻烦,要求你把「完成」翻译成一条能跑出 true 或 false 的断言,而不是一句自然语言。但正是这一步翻译,逼着人把模糊的完成标准明确下来。举个反例,早期我在模板里写的是「确认订单已归档」,校验脚本拿到这句话根本没法执行,最后只能退化成人工抽检,抽检比例 5%,漏掉的 95% 就是前面那 25 条。

现在的断言写法长这样,用的是模板里约定的轻量表达式:

assert:count(orders where order_id in {batch_ids} and archive_flag = 1) == len({batch_ids}) assert:max(archive_log.created_at where task_id = '{task_id}') >= task_started_at

第一条保证主表全量更新到位,第二条保证流水不是历史遗留的。两条同时为真才算 verified。这个套路听着繁琐,但写习惯了之后,一个新任务加断言大概两分钟,比自己手动写校验 SQL 快。

2.3 模板里那个每次评审都有人想删的字段

idempotency_key。这个字段在评审会上被质疑过三次,理由是「任务 ID 本身就是唯一的,再搞一个幂等键是冗余」。这个理由听起来很有道理,直到你遇到重试。

场景是这样的:工具调用超时,智能体按策略重试一次,第二次成功了,数据库里出现两条归档流水。或者反过来,第一次其实已经写成功,只是响应超时,重试时被幂等键挡掉,工具返回「已处理」,智能体认为是新处理,加了两次计数。这两种情况下,任务 ID 都不足以区分「同一次业务操作的第 N 次尝试」和「两次不同的业务操作」。

所以幂等键必须由业务语义构成,而不是由任务 ID 构成。那批订单任务里,我用的是archive:{order_id}:{target_date},同一个订单同一天归档只算一次,重试天然幂等。这个粒度是我踩了坑之后才定下来的,最早的版本用的是{task_id}:{order_id},任务 ID 在里面,重试就失效了,等于没做幂等。

注意:幂等键的粒度决定了重试是否安全。判断方法是问自己一句——「同样的输入重跑三次,业务上应该产生几条记录」。答案是一,幂等键里就不能出现任何每次执行都会变的值。

3. 数据库不认账,我按什么顺序往下挖

3.1 第一刀切在连接层:事务提交与可见性延迟

排查这类问题我有个固定顺序,从底往上,先怀疑最不性感的地方——连接。第一步永远是确认「写」和「读」是不是同一个库、同一个 schema、同一个账号权限。

具体操作是三条命令,随手记在排查手册里:

# 确认任务执行时实际使用的连接串指纹(不含密码) env | grep -E "DB_HOST|DB_NAME|DB_SCHEMA" # 确认主库当前活跃事务与最近提交时间 SHOW ENGINE INNODB STATUS; -- 或等价的诊断视图 # 确认读操作走的是主库还是副本 SHOW VARIABLES LIKE 'read_only'; -- 副本通常为 ON

第二刀切可见性。如果写确实发生在主库,读却从副本走,那就有主从延迟的窗口。这种问题有个很典型的特征:立刻查查不到,过两分钟再查就有了。区分方法很简单,用主库连接和副本连接各查一次,结果不一致就是延迟。这类问题的修复不在于代码,而在于把「校验查询」强制走主库,或者在校验前加一个基于 binlog 位点的等待。26H2 模板里我把校验连接单独配了一个verify_datasource,明确标注只允许指向主库,就是为了堵这条路。

第三刀是事务边界。工具内部如果有多次写入,必须确认它们是不是在同一个事务里。我见过一个实现,更新主表开了一个事务,写流水又开了一个事务,中间任何一步失败都会导致半成品状态。那 25 条「流水有、主表没有」的记录,根因就在这里:主表更新先执行,流水写入后执行,两次独立提交,主表那次因为一个触发器报错回滚了,但流水那次成功了,工具层没感知到这个差异,返回了成功。

3.2 第二刀切应用层:幂等键错配与静默跳过

幂等键用错的表现非常有欺骗性——工具不报错,日志里写着「冲突已处理,跳过」,一切看起来都很正常。但业务意义上,这次跳过可能是误判。

判断幂等键是否错配,最直接的办法是把幂等键的构成打印出来,和业务主键对照着看。判断标准就一条:如果两次不同的业务操作算出了同一个幂等键,那就是错配。我那次的错配是把批次号放进了幂等键里,同一批里如果两条订单属于同一个批次号,第二条就被跳过了。批次号是循环变量,不是业务标识,这个错误现在看很低级,当时却花了大半天。

还有一类更隐蔽的静默跳过,来自「更新条件不匹配」。比如UPDATE orders SET archive_flag = 1 WHERE order_id = ? AND status = 'PAID',如果某些订单的状态是REFUNDED,这条更新影响 0 行,但 SQL 本身不报错。工具拿到 0 行更新结果,如果写的是if rows >= 0: return success,那就等于把「没更新」也算成了成功。正确的写法是if rows == expected_count,期望值由任务参数传入,不匹配就直接抛错。

检查项错误写法正确写法
更新结果判断rows >= 0rows == expected_count
幂等键构成含批次号/循环变量/时间戳仅含业务主键 + 业务日期
异常处理catch 后打日志返回 successcatch 后抛出或返回结构化失败
冲突处理DO NOTHINGDO UPDATE并记录冲突次数

3.3 第三刀切智能体层:工具返回值被「善意」改写

这条路最容易被跳过,因为大家默认智能体只是调用方,不会篡改数据。但实际排查中我遇到过两种改写。

第一种是摘要式改写。工具返回的是结构化 JSON,里面包含inserted、skipped、failed三个计数。智能体的提示词里要求「用一句话总结执行结果」,模型看到 inserted=61、skipped=25、failed=0,总结成了「本批次处理完成,无异常」。从语言上讲没毛病,从运维上讲全是误导。后来我在提示词里加了一条硬约束:日报的完成字段必须直接填failed > 0 or skipped > 0 ? 'PARTIAL' : 'SUCCESS'的计算结果,不允许模型自由发挥。

第二种是重试时的结果合并。智能体重试了一次,前一次其实成功了只是超时,于是拿到的两个返回值一个有 inserted=25、一个有 skipped=25,模型合并成了「处理 50 条」。这类问题靠提示词堵不住,只能在模板层面要求所有计数来自数据库聚合而非工具返回。26H2 模板里我加了一个verify_counts_source字段,可选值是tool_return或db_aggregate,生产任务强制用后者。

提示:判断「智能体有没有篡改结果」有个土办法——把工具原始返回值完整落盘到 trace 存储里,和日报里的数字做 diff。连续抽样 20 条,只要有 1 条对不上,就说明链路里存在改写点。

4. 表格画布落地:把状态机摆到一块能点的表上

4.1 为什么选表格画布而不是另起一套看板

原因很实际:数据在这,人在这。任务的原始台账本来就躺在电子表格里,运营同学每天在这张表上做核对,运维同学在这张表上标异常。让他们再登录一个独立看板系统,多一个账号、多一次跳转,到头来还是回到表里干活。

表格画布的做法是,在同一份工作簿里叠一层画布区域,把关键的状态分布、异常清单、待办队列以卡片和色块的形式铺上去,数据仍然由单元格承载,画布只负责展示和交互。好处是双向的:画布上的点击可以回写单元格,单元格的变化可以驱动画布刷新。整个东西不需要额外部署,也不需要数据库权限,谁有表的编辑权谁就能用。

我这套画布一共三块区域,加起来占一屏多一点,每块对应一个具体问题。

4.2 任务视图:用色块把「卡住的」顶到眼前

第一块是任务总览,横轴是时间(按小时分桶),纵轴是状态。核心是让pending_verify和failed这两类显眼起来。

具体搭法:主数据区是任务台账,A 到 M 列放字段,其中状态列用数据验证限定为前面那八个值。然后在画布区用一组联动单元格做计数,公式大致是:

=COUNTIFS(任务台账!$F:$F, "pending_verify", 任务台账!$C:$C, ">="&TODAY()) =COUNTIFS(任务台账!$F:$F, "failed", 任务台账!$C:$C, ">="&TODAY()-1)

计数结果绑到画布上的卡片,再用条件格式把数字染成安不安全色:pending_verify 超过 5 个变黄,超过 20 个变红。这个阈值不是拍脑袋定的,是我们统计了两周的正常波动之后定的,日均 pending_verify 峰值在 3 个左右,超过 5 就说明校验环节在堵。

任务视图里我还放了一个「超时未校验」的列表,逻辑是筛出pending_verify且tool_returned_at距今超过 30 分钟的行。这块名单每天大概出现 2 到 8 条,是排查的起点。之前那 25 条没落库的记录,如果当时有这块名单,当天下午就会被发现,而不是等到第二天核对交付。

4.3 证据视图:把 assertion 的结果摊开

第二块是证据链视图,一行一个任务,列是三元组加上 assertion 的执行结果。这块的价值在于让「校验通过」从一句结论变成一组可看的数据。

字段对齐到 26H2 模板是这样的:

画布列模板字段数据来源刷新方式
任务号task_id台账实时
调用链路trace_id工具回执表每次执行写入
断言内容assertion模板配置版本变更时更新
断言结果verify_result校验任务写回校验完成后
实际影响行数affected_rows数据库聚合每次执行写入
期望行数expected_count任务参数创建时写入

verify_result我只允许三个值:PASS、FAIL、NOT_RUN。加一个 NOT_RUN 是为了把「没跑校验」和「校验失败」分开。这个区分很关键,因为两者的处置方式完全不同——FAIL 要查业务逻辑,NOT_RUN 要查调度和权限。早期我把两者都归到「未通过」,结果排查方向经常跑偏。

画布上这块用条件格式做了双色标记,PASS 绿、FAIL 红、NOT_RUN 灰。灰格子数量是衡量这套体系有没有真正跑起来的指标,如果灰的比绿的多,说明校验任务本身没在跑,模板再漂亮也是摆设。

4.4 异常视图与刷新策略:几个不那么显然的坑

第三块是异常清单,把 failed、pending_verify 超时、assertion 为 FAIL、affected_rows 与 expected_count 不一致这四类捞出来,聚合成一张待处理列表,每行带一个「已处理」的勾选,勾上之后写回台账并记录处理人。这块的交互靠画布上的按钮触发一段轻量脚本,脚本做的事情很少,改一个单元格的值、追加一行处理记录,仅此而已。

坑主要集中在刷新上,我踩了三个。

第一个坑是公式重算的时机。画布上的卡片绑的是公式结果,如果工作簿设成了手动重算,画布显示的就是旧数。这个问题在打开文件时最容易误导人,因为屏幕上清清楚楚写着「0 个异常」,实际后台已经堆了三十条。解决办法是把这块区域所在的表设成自动重算,或者在画布上加一个显式的刷新按钮,让人养成点一下的习惯。

第二个坑是并发编辑。画布是可点的,两个运营同学同时点「已处理」,后写的覆盖先写的,处理记录就丢了。这个靠表格本身解决不了,只能靠约定:异常视图的处理动作改成「登记」而不是「覆盖」,也就是点一下往处理记录表追加一行,而不是改状态列。追加操作天然幂等,两个人同时点最多多一条重复记录,不会丢信息。

第三个坑是权限边界。画布上有个别按钮能触发写回,如果整张表对所有人开放编辑,等于把写权限也开出去了。我们最后的做法是把工作簿拆成两份,一份只读展示给业务方,一份可编辑留在运维组内,靠一个定时任务从主表同步到展示表。多了一步同步,但省了很多「谁把数据改了」的扯皮。

5. 复盘时我改掉的几个习惯

这套东西跑稳之后,我回头看,真正起作用的不是模板本身,是几个被强行改掉的习惯。写出来给同样在做智能体工作流的人参考。

第一个是不再相信「成功」这个词。工具返回值里凡是出现 success、ok、done 这类字眼的,我都会追问一句:它代表的是调用成功,还是业务成功。这两件事在 90% 的实现里被混为一谈。现在的做法是所有工具返回值必须带结构化计数,不允许只有布尔值。

第二个是把校验当成任务的一部分,而不是任务之后的事。很多人把校验写成独立的对账脚本,每天跑一次。这种方式的问题是反馈周期太长,出了问题要等一天。26H2 模板把校验塞进状态机,任务不校验完就不算完,反馈周期从一天缩到两分钟。代价是每条任务多一次数据库往返,这个代价在批量场景下可以合并——一批 50 条任务合并成一次聚合查询,成本几乎可以忽略。

第三个是让失败可见。前面提到的异常吞噬,本质是怕失败打断流程。但流水线真正需要的不是不失败,而是失败得清楚。我现在的做法是工具层不 catch,让异常往上抛,由调度层统一捕获并写入 failed 状态,同时在画布上飘红。听起来很激进,实际跑下来失败率并没有上升,只是原来被藏起来的那部分暴露了。

最后一个算是经验之谈:智能体工作流里,最不值得信任的就是它自己写的日报。日报可以看,但只能当索引用,真正的结论要去数据库里捞。我现在看日报的习惯是先扫一眼有没有 PARTIAL,有就直接跳到证据视图,从 assertion 结果看起,全程不看那句自然语言总结。这个习惯养成之后,类似「报完工不认账」的问题,从发现到定位基本能压到半小时以内。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询