← 返回文章列表
Data Agent 工程实践 · 第 4 篇

Data Agent 工程实践(四):把第一版做成可维护的工程

同一句“查1月app净收入”,怎样在TypeScript、Rust和模型工具之间保持同一个意思?沿时间条件、逐调用预算和独立核算,解释类型与验证的区别、未知用量为何不能归零,以及怎样从页面一路读到业务规则和平台适配器。

Data Agent
Agent Engineering
数据工程
合集目录:Data Agent 工程实践第 4 / 4 篇

前三篇沿着同一个问题走了一遍:查 2026 年 1 月各渠道净收入,补充“只看 app”,确认执行,再解释为什么是 16 元。资料、条件、SQL 和结果都有了归属。接下来,维护者还要能判断:改一条规则会影响哪里,调用为何被拒绝,一项测试究竟证明了什么。

这个项目由我确定需求和取舍,Coding Agent 完成了大量实现。这样的第一版要继续用下去,我需要读懂它实际执行的约定。例如,页面、Pi 工具和 Rust 都使用“结束时间”,三边是否把它理解为同一个边界?模型收到工具结果后再次生成回答,这次调用是否仍受原预算约束?

本文以公开提交 6801df7 为源码依据。示例继续使用从零构造的订单资料和合成 SQLite 平台。文中引用既有验证记录,不把局部验证延伸为生产认证、真实企业平台接入或容量保证。

一份约定,生成两种语言的类型

跨语言契约,是进程之间对字段、类型、必填项和错误含义的共同约定。React 和 Node.js 使用 TypeScript,业务服务使用 Rust。如果三边分别手写一份定义,改动很容易只落到其中一边。

项目把共用定义放在 packages/contracts/schema.json。HTTP 接口由 OpenAPI 3.1 描述,并引用这份 JSON Schema;Pi 协议、工具参数和事件数据也使用其中的定义。Schema 描述实际交换的 JSON,语言类型从它生成。

生成器使用 quicktype 输出 TypeScript 和 Rust 类型,同时生成契约名称映射。生成时关闭日期推断,时间仍保留为 JSON 字符串,避免语言库自行变成日期对象后改变原值。生成器的 --check 模式重新生成内容并比较,发现与已保存产物不一致就报错。

类型帮助程序员写代码,却不能检查网络刚收到的数据。TypeScript 的 as T 不会把错误 JSON 变成合法对象;Rust 能把字段读成 String,也不表示字符串符合时间规则。因此,两边收到边界数据后,还要按同一份 Schema 做运行时验证。

下面是 Node 验证入口的实际代码。validators 是此前用 Ajv 为各个 Schema 定义编译出的验证器。

export function validateContract(name: string, value: unknown): void {
  const validator = validators.get(name);
  if (!validator || !validator(value)) throw new Error('invalid_input: ' + name);
}

Rust 对应入口使用 jsonschema,开启格式验证,先验证 JSON,再反序列化成生成类型。两种语言分别运行自己的验证器,规则来源相同。

同一份JSON Schema分别生成TypeScript和Rust类型,并为两侧运行时验证提供规则,合法输入随后进入业务校验

生成类型约束代码中的使用方式;运行时验证检查实际收到的值。权限、版本和状态还要由业务代码判断,Schema 不会替用户授权查询。

把“1 月”写成双方都能检查的边界

时间条件能说明为什么字段名和类型还不够。

time_end: string 接受 2026-01-31,也接受 2026-02-01T00:00:00Z。但两者不能直接交换使用。前者没有说明时区和时刻,也没有说明是否包含终点。若程序把它当作 1 月 31 日零点,整个月最后一天的大部分数据都会漏掉。

当前 Conditions 定义把终点写成如下约束。这是实际 Schema 摘录。

"time_end": {
  "type": ["string", "null"],
  "maxLength": 64,
  "format": "date-time",
  "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\\.[0-9]{1,6})?Z$",
  "description": "时间区间的不包含终点。按明确的业务时区解析后转为UTC完整时间戳,以Z结尾;整月结束为下一月起点,不能写本月最后一天。未指定时为null。"
}

这里使用 RFC 3339 时间表示,并进一步要求统一存成以 Z 结尾的 UTC 时间。小数秒可以省略;出现时必须是 1–6 位,例如 .123456Z。正则限定写法,date-time 格式验证再检查日历是否合法,所以 2026-02-30T00:00:00Z 也应被拒绝。

本系列合成资料按 UTC 归属月份。2026 年 1 月对应 [2026-01-01T00:00:00Z, 2026-02-01T00:00:00Z):包含左端,不包含右端,称为左闭右开区间。SQL 使用 paid_at >= start AND paid_at < end。这样不用猜月底最后一刻有几位小数秒,2 月 1 日零点也只会进入下一月。

业务时区和存储格式要分开。如果业务另行规定按北京时间统计 1 月,先确定本地的月初和下月月初,再换成 UTC,边界应为 2025-12-31T16:00:00Z 至 2026-01-31T16:00:00Z,并另存 timezone=Asia/Shanghai。这只是时区换算示例,不改变本系列的 UTC 口径。

未知边界可以保留 null。修改条件时,未提及的字段保持原值,明确清除则使用 unset。缺少业务时区时,程序也不应为了通过验证擅自补出一个时区。格式合法只证明值符合约定;“用户说的 1 月究竟是哪一个月”,以及 SQL 有没有使用相同边界,仍需要语义判断和行为验证。

契约升级时,先认出已经发生的操作

收紧格式会影响新请求,也会遇到旧记录。假设某次工具调用已经保存了任务,但进程在 Pi 收到工具结果前退出。旧调用里仍是日期字符串,新版本现在要求完整 UTC 时间。

如果恢复入口先套用新规则,再告诉模型“参数错误,请重新调用”,模型可能新建一个调用,把已完成的副作用再做一次。因此,恢复必须区分“从未登记的新动作”和“已经登记、正在找原结果的动作”。

数据工具登记与执行入口先核对当前运行权限、SDK 调用标识和原参数指纹。指纹用于判断这是不是原来那组工具名称与参数。对允许重放、已有确定回执的操作,系统沿原 operation_id 接回结果;真正的新执行仍须通过当前契约。

已登记却没有确定回执的旧格式调用,返回 outcome_unknown,表示操作结果未知。它不能被描述为“肯定没有执行”,也不能通过替换操作标识来重做。读取资料仍有当前有效性检查,历史回执不会因此获得新的权限。

Node 工具适配保留了相同区分:恢复用原记录向 Rust 查证,只有登记阶段明确拒绝的新参数才作为可修正错误交给模型。这条恢复路径不改历史日期,也不凭空补做数据迁移。

维护契约时,需要同时考虑下一条输入和上一条未收尾的输入。已有状态是否能被正确认出,决定了升级后是否会重复执行。

预算许可放在模型真正发送之前

一次用户请求可能产生多次模型调用:先读资料,再生成 SQL,再根据工具回执解释结果。超时后的恢复、查询结果通知和摘要也可能继续调用模型。只在用户点击发送时扣一次次数,限制不到这些后续调用。

项目为每次实际模型请求分配 call_attempt_id,并把它记入持久账本。原请求对应一个预算范围,保存调用次数和模型配置;上层账本再限制累计次数与费用。恢复运行和原查询的结果唤醒继续使用对应的原预算范围,不能因为换了运行编号就重新获得额度。

预留、发送和结算分别处理不同事实。

预留时,Node 完成请求体,计算参数指纹,再向 Rust 请求 reserve。Rust 核对运行与资料是否仍有效,按配置的输入上界和最大输出计算保守预留。在事务中,已结算金额、仍被占用的预留和本次新预留之和不能超过限额;调用次数也必须有余量。预留成功还不允许发送。

真正调用网络前,Node 再核对实际请求体的指纹,并请求 send。Rust 检查原预留、期限、运行权限和账本状态,保存 issued,才返回发送许可。下面摘自 DeepSeek 适配器。

const permission = decodeContract<ModelCallReceipt>('ModelCallReceipt',
  await transport.post('/internal/model/send', 'SendModelCall', binding()));
if (!permission.send_allowed) throw new Error('model_receipt_unknown');
sent = true;
const response = await fetch(input, { ...init, signal, redirect: 'error' });

issued 证明本地已发放一次发送许可。它不能证明服务商收到请求,更不能证明调用完成。同一调用重复申请发送时,Rust 不再发放第二次许可;这是为了在网络回执不确定时保留原调用身份。

每次模型请求先预留,再获准发送;有可靠usage时结算,没有usage时保留unknown和原预留,同一许可不能重复发送

图中描述普通 Pi 模型调用的持久账本。issued 是发送许可记录,settled 是用量已结算;两者之间的未知不能靠重发请求消除。

响应带有可靠的 usage,也就是提供方报告的输入、输出用量时,Rust 按原价格配置计算费用,扣除这次预留并记入已结算金额。这里的金额是应用按配置费率核算的估算值,不是服务商账单。若实际用量超过预留所依据的上界,账本会记录超限,后续调用不再照常放行。

没有可靠用量时,finalize 把状态记为 unknown,保留原预留。HTTP 401 说明认证失败,但单凭这个状态码,应用不能伪造一份零用量回执。当前实现不因此宣称服务商已经收费,也不自动把预留归零。如果进程来不及提交 finalize,记录还可能停在 issued,同样不能算作费用已结清。

这会让未知调用继续占用额度,是一个保守取舍。结算请求可以携带原内容重送,幂等检查会阻止重复入账;重送结算与重新发送模型请求是两个动作。Rust 账本实现把这些状态分别保存,失败时仍能查明哪一笔没有收尾。

上下文压缩不等于费用预算重置

上下文窗口限制一次模型请求能容纳多少内容。累计费用和调用次数限制整个预算范围能做多少工作。即使每次请求都很短,重复调用仍会累积费用;把历史压短,也不会退回已经发生的调用。

另一个容易混淆的限制是请求体字节数。中文资料、工具定义和 JSON 包装都会占字节,而 token 是模型处理文本时使用的计量单位,二者不是固定换算关系。项目在发送前检查实际请求体字节数,同时按模型配置预留 token 用量;这道检查不能被理解为精确预测提供方计费。

Pi 会话装配启用 SDK 原生 compaction,即把较早历史整理为摘要,并保留近期内容。宿主估计下一次请求过大时,在会话空闲阶段调用 session.compact();活动轮内的上下文溢出则沿用 Pi 的原生处理。应用没有再维护一套独立摘要循环。

普通自动重试在 Pi 设置中关闭,提供方 SDK 的 maxRetries 也设为零,避免隐式网络重试绕过逐调用记账。Pi 的压缩后续接仍然会产生新的模型调用,摘要本身需要模型时也一样;这些调用经同一个受控适配器,继续消耗原预算。

摘要只能帮助模型继续理解,正式任务条件和查询版本仍保存在业务记录里。资料失效后,发送入口也继续核对有效性。原生压缩回归检查同一会话能否续接、摘要调用是否入账,以及压缩后撤回资料能否在发送前被拒绝。它使用本地模型替身,不能据此断言真实模型的摘要保留了全部业务细节。

测试各自回答一个范围内的问题

“通过”要带上对象和环境。类型检查通过、流程通过、SQL 算对、解释正确,是不同结论。把它们合成一个总通过率,会掩盖最需要继续检查的地方。

五类验证分别检查数据约定、故障分支、实际服务集成、真实模型行为和企业平台能力,每一类只支持对应范围的结论

图中按验证对象分组,不是本项目全部已通过的清单。真实企业平台仍待接入验证;真实模型的证据也只对应实际运行过的场景和版本。

跨语言契约集把相同正反例交给 Node 和 Rust,比较接受与拒绝结果;指定往返样本还会检查序列化后原值是否保持。完整 UTC 时间、非法日期、缺少时区和明确清除条件都能在这一层测试。它不负责证明净收入口径正确。

确定性替身让故障可重复。所谓确定性,是相同输入按预定规则返回相同结果。例如,本地模型端点可以固定返回 401、缺失 usage 或延迟响应,再检查程序是否重复发送、是否保留预留。这能验证失败分支,不代表真实模型会正确选择工具。

真实 Rust、MySQL、Pi 与合成平台的集成检查,进一步验证事务、状态恢复和实际 SQL 执行。这里的 MySQL 保存应用记录,合成 SQLite 保存订单数据。虽然代码和数据库真实运行,平台权限、方言和终态行为仍只是这套合成环境的约定。

SQL 结果另由独立核算器核对。标准答案和参考 SQL 由验收进程读取,不作为模型知识资料。套件还检查模型请求体没有带入 expected_rows、sql_file 等答案字段。仅检查输出碰巧等于 16 元不够,核算器还改变数据,观察同一条 SQL 的结果是否随之改变。

例如,增加一条符合月份、渠道与非测试条件的 app 订单行,付款 900 分、退款 123 分,净收入应增加 777 分。原来的 16 元应变成 23.77 元。写死 SELECT 16 会露出问题;错误换算、遗漏渠道或重复关联,也可以用相应变体区分。核算器自身的反例集还检查错误列角色、常数结果和金额单位,避免验收端只挑一列“看起来对”的数值。

真实模型小样再回答模型能否主动补查、澄清和解释。已有质量报告仍保留错误业务推论、必需事实遗漏、未知调用和核算器覆盖不足;机器流程通过不会抵消这些差异。报告对应各次冻结输入,不能因源码链接指向当前提交,就视为当前全部能力的质量验收。

真实企业平台还需验证身份、数据权限、执行语义和故障查证。前面几层无法代替这一层。Makefile提供契约、查询、恢复、模型适配和原生压缩等检查入口;维护者要读具体命令和断言,不能从目标名称推断覆盖范围。

沿一个页面现象,找到负责的模块

要维护 Coding Agent 写出的代码,可以从一个可观察的现象开始。比如页面一直显示“提交结果待查证”。这段文字对应什么状态?谁保存状态?谁负责向平台再次查证?

QueryCard.tsx 把 submission_unknown 映射成页面文案,负责展示和发起用户操作。HTTP 路由接收请求,建立访问上下文,再调用 query_workflow。这个业务用例组合权限、查询状态和后台推进;查询模块的私有 store.rs 保存自己的记录,平台适配器负责提交、查询状态和读取结果时的协议调用。

沿这条路径读,修改位置会逐渐清楚。文案问题落在页面;过期版本仍能确认,要查业务用例和查询规则;平台回执含义变化,要看适配边界及其调用者。只在页面隐藏按钮,无法修复服务端仍接受旧版本的行为。

这也是模块边界的用途。业务用例表达跨模块协作,领域模块集中维护自己的规则与存储。API 和 Worker 复用这些业务能力,外部平台的响应在接入处转换。维护者能追到一条状态由谁修改,就不必每次都从整个仓库重新猜测。

项目的 docs/CURRENT.md 保存唯一当前状态:有效决定、本轮范围、验证证据和未完成项。架构入口解释组件及数据归属;源码确认实际行为;测试与收据说明哪个版本、哪种环境曾经验证了什么。历史计划有助于理解取舍,但不能覆盖当前记录,更不能替代源码。

回看这四篇,“1 月 app 净收入是 16 元”背后已有一条可阅读的路径:定义来自资料,条件属于任务,SQL 绑定版本,执行需要授权,结果回到原查询。跨语言契约、预算账本和分层验证,让维护者继续追问每一步的前提、责任与证据。第一版仍有质量缺口;后续修改可以沿这些具体边界进行,并保留能识别回归的检查。

继续读源码时,可以从以下入口开始。链接均固定到本文提交。

  • 契约真源:先选一个字段,再对照生成器和两侧运行时验证。
  • 预算业务入口:看发送资格如何检查,再进入 modules/runtime/model_calls 读账本状态。
  • 查询卡片与查询用例:从界面状态追到业务动作,再读 modules/queries/store.rs 和 platform.rs。
  • 架构入口与当前记录:分别用于定位职责,以及核对有效决定和证据范围。

分享这篇文章

复制链接,或分享到你常用的地方。

评论

发表评论

0 / 1000