Skip to content

从第一份报告到可恢复的研究程序:Open ETF Agent 的第二个阶段 ​

介绍 v0.1 时,Open ETF Agent 只做一件小事:读取两份资料,完成比较,保存报告,再让另一个进程读回原文件。那时最重要的问题是,模型说“完成了”,究竟对应什么真实发生的事情。

到了 v0.10,问题往前走了一步:当资料要经过选取、计算和交付,执行还可能跨越进程,怎样让后来的消费者继续使用原来的事实,而不是各自拼出一份看起来相同的结果?

这就是第二个阶段的主线。它不是把所有模块装满,也不是宣布一个已经能独立完成真实基金研究的产品。公开工程仍用合成资料和脚本动作检查执行机制,但已经能沿两条可复现路径看清系统:一条从原始表走到报告与 CSV,另一条在提交进程退出后,由独立 Worker 继续同一个 Run,并读回已提交正文。

这两条路径共享执行基础,却是不同示例。下面先沿第一条看清数据怎样流动,再用第二条解释程序的生命周期;不把它们拼成一次已经运行过的全功能任务。

一、不是多加几个工具,而是补上结果之间的联系 ​

v0.1 的简单设计有它的理由。资料读取、一次计算、文件保存都在一个本地流程里完成,先分清三个职责已经足够:模型网关负责接收和整理模型响应,运行时负责执行循环,工具宿主负责校验参数并产生真实效果。

后来的演进没有推翻这条主线。新增问题是,一个环节成功,并不能替下一个环节作证。

读到一页,不等于计算使用了那页里的数;算出一个数,不等于下一次请求拿到了这个结果;报告和 CSV 都有数字,也不等于它们来自同一次计算。若只是让每个工具分别返回“成功”,这些缝隙仍然存在。

Result / Reference 的原始提案因此没有要求再建一个结果数据库或计算平台。原始结果已经能保存,运行时已经会把工具结果送回网关,报告和 CSV 也已经是可交付文件;缺的是这些环节之间可核对的对应关系。

v0.10 补的正是这条关系:从已保存的原始内容里选取输入,让现有计算器计算,再让下一请求、报告和 CSV 使用同一个派生结果。这里新增的是一项完整能力,而不是另一套执行流程。

二、跟着一张小表,走到报告和 CSV ​

示例的输入是一张明确标为合成数据的表。它刻意保留三行,数值分别是 100、999 和 250;单位一致,币种都是 USD,数据日期都是 2026-10-01。任务比较第一行与最后一行,中间那行不是计算对象。

这比直接把 100 和 250 交给计算器多了一层要求:程序必须能说明,这两个操作数究竟来自哪里。

先取得原始内容,再选定操作数 ​

read_comparison 通过既有工具宿主取得固定资料。Result 保存原始内容和身份,计算请求随后提交原始结果引用、选区以及两个行位置:

json
{
  "resultRef": "取得资料后返回的原始结果引用",
  "selection": { "offset": 0, "limit": 3 },
  "baselineRow": 0,
  "currentRow": 2
}

这是请求形状示意,运行时的引用由工具返回。计算器从已保存的原始字节中解析数值,而不是相信调用方再抄一遍的数字。引用、内容哈希、实际选区和行位置随派生结果保留下来。

这也解释了为什么要保留尾行这个小设计:如果只选前两行,却仍要求使用第三行,程序不能因为表里“可能还有数据”就补一个 250。选择范围没覆盖所需操作数,这次比较就不可用。

即使选区完整,也只说明选定的已存储行被完整取得,不代表上游资料覆盖了全部持仓,更不代表模型理解了整份资料。

把数值口径与结果一起传下去 ​

对这张表,差值是 150,当前值是基准的 2.5 倍,增量相对于基准是 1.5,也就是 150%。这些不是可以互换的三种说法。

派生结果同时保留操作数、单位、币种和数据日期。如果日期或币种不一致,或者分母未知,当前这项“同一快照比较”方法就不能给出一个貌似正常的绑定结果。程序返回具体的不可用原因,不把未知当成零,也不猜一个分母。

限制跟着结果一起走,才不会在后续表达里消失。运行时把工具结果加入后续输入;针对生产网关编码的验证,会检查实际编码出去的请求是否携带相同引用、数值和限定条件。这里使用注入的离线传输检查请求,不把“请求中存在这些字节”说成“真实模型已经据此正确推理”。

报告和附件不再各算一遍 ​

生成 CSV 时,传入的是派生计算的引用,而不是另一份手工整理的数字。保存报告时,工具宿主也从原计算结果生成数值部分。两份交付物因此可以回到同一计算,再回到同一原始选区。

报告里的自由叙述仍是另一回事:程序生成的数值部分可以核对,并不意味着任意文字结论都自动获得支持。这个区分让系统既能明确保证自己掌握的关系,又不越权替模型作语义判断。

文件落地后,Artifact 与 Revision 标识具体交付物及其修订。独立进程读取原来的报告和 CSV 字节,核对回执,而不是再次调用模型生成一份相似答案。编号前保留的独立回读记录保留了这条路径;数值结果和文件身份可以一起检查。

这个例子带来的变化很具体:现在不仅能问“有没有保存”,还能继续问“保存的数字用了哪几行,报告与附件是不是同一个结果”。

三、进程可以更换,执行身份不能重置 ​

文件能读回之后,还有另一类问题:提交任务的进程退出了,谁继续执行?它接手时,是继续原任务,还是重新做了一遍?

这不是上面比较示例的隐藏步骤。公开工程用另一个 durable-publication 示例验证持久执行与正文交付,资料和动作同样是合成的,SQLite 提交和进程交接则真实发生。

这里也有一次值得保留的取舍。早先的 Worker 方向曾考虑更大的队列机制,后来单 Run 提案收窄为:既然只接手一个明确指定的 Run,就复用 Session 已有的执行租约,不再复制一套 Task 状态。

于是几个职责保持分离:

  • Session 保存原任务定义、预算、截止时间和检查点,并决定当前谁有权写入执行状态。
  • Runtime 继续负责派发和恢复;Worker 没有自己的第二套执行循环。
  • Worker 驱动选定 Run,管理自身进程生命周期,并读取原有执行事实。
  • Publication 与 Artifact 分别保存已提交正文和文件交付,不把它们的存在寄托在进程是否还活着上。

这样,提交命令可以先退出,另一个进程再接手,但它不能重置已消费预算、换掉原授权或把截止时间向后推。已经有持久结果的操作,不因换进程就再做一次。

更重要的是,可恢复不等于遇到任何中断都能放心重试。 若请求已经派发,却没有权威结果,系统保留结果未知的事实,并拒绝自动重放;仅仅启动一个新 Worker,不会让重试变得安全。Worker 被停止也不自动等于用户取消任务。这些情况决定后续动作,不能压成一个笼统的“失败后重试”。

正文提交还有自己的边界。一个回答块写入 Publication 后,后续 Run 失败不会抹掉它;需要替换正文时,按原修订关系显式提交,而不是把“最新输出”当成原答案。附件不可用,也不应反过来撤销独立合法的正文。

因此,整个 Run 的状态、某段正文是否已提交、某个文件是否已交付,是相关但不同的事实。让它们各自保留身份,读者才有办法知道中断后还剩下什么。

四、在固定版本上运行这两条路径 ​

本文以 v0.10 固定源码为准。日常使用入口见当前 main README,不要把后续版本的命令或恢复能力套回这个快照。

使用 Node.js 26.11.1 可对齐现有验证环境。默认示例不需要模型密钥;安装依赖可能访问 npm,安装后这两条示例离线运行。公开实现是原生 JavaScript ESM,build 检查并复制源码与资料,不是 TypeScript 类型检查。

bash
git clone https://github.com/hpoenixf/open-etf-agent.git
cd open-etf-agent
git switch --detach ff5b9273ef0d239e804f61792702cd2e94174be7
npm ci --ignore-scripts --no-audit --no-fund
npm run build

node dist/src/cli.mjs --case result-consumption --output runs/article-v010

CLI 会返回实际 Run 目录和交付回执。沿这个目录查看 report.md 与 manifest.json,再按清单中的附件身份查看 CSV:重点不是只看到文件存在,而是对照原始来源、计算引用、数值口径和附件回执。

保持在仓库根目录,另用一个独立的 session 输出目录,连续执行下面三条命令,观察“提交退出→独立进程执行→原正文回读”:

bash
node dist/src/session-cli.mjs submit --session runs/article-worker --run original-example --case durable-publication
node dist/src/session-cli.mjs work --session runs/article-worker --run original-example
node dist/src/session-cli.mjs publication --session runs/article-worker --run original-example

这个示例的 30 秒截止时间从提交时开始计算,不是从 work 开始。第一次命令返回后进程已经退出,第二次调用的是另一个进程;第三次只读取正文与原回执,不再执行模型动作。再次实验时使用新的 session 目录,不覆盖旧结果。

这组命令展示跨进程接手,不等同于在任意外部调用中强杀后都能透明恢复。安全检查点上的续跑、停止与未知效果分别由Worker 进程用例和恢复路径约束。升级还有一项实际限制:v0.10 使用 tool-host@2,旧 tool-host@1 的活动检查点不能跨版本续跑;先在原版本结束活动 Run,旧的不可变交付物仍可读取。

五、这时再看系统,哪些复杂度值得留下 ​

从这两个例子往外看,其他机制的位置就更容易理解了。

Skill 和 MCP 让方法与外部工具接入同一宿主;受限 Child 可以分开上下文,但仍受根预算约束;Result 与 Context 区分原始内容和送入请求的材料;压缩便笺不能替换原文,显式 Memory 也不能把一次临时输入悄悄变成长期偏好。它们的价值不是增加一个模块名,而是解决主链上已经出现的特定问题。

也正因为如此,公开版没有必要把这些机制的完整形态一次装齐。当前 Child 示例还是脚本分工,Worker 不是定时调度平台;PDF 输入属于后续工作,不是本文固定版本已有的入口。要理解各模块更完整的设计,可以从Open ETF Agent 专题进入原有文章,再对照各自公开实现的范围。

第二个阶段真正形成的,是一套可以继续增加研究能力的工程骨架:原始事实能被找回,派生结果能被追溯,后继消费者使用了什么能够检查,进程更换也不会自动改写执行身份。 这让我们能把“能运行”拆成具体问题,而不是用一句“Agent 已完成”概括一切。

接下来是否要增加新机制,可以继续用同一个标准判断:它补上了哪段尚未成立的关系?如果只是再包一层已经可读的接口,未必值得;如果能让原本断开的输入真正走到消费者,就有清楚的价值。

v0.1 文章保留最小起点,模块文章解释各自的设计来路,这篇负责把当前公开工程连成一张可运行的地图。欢迎从公开仓库运行一个例子,再沿原始资料、结果和交付逐步改动它;如果这条演进路线对你有帮助,也欢迎 Star 或提交可复现的问题。

与个人博客并行:这里按问题连续阅读,博客保留时间线。 · 联系邮箱:hpoenixf@foxmail.com · 微信:hpoenixf0510