Skip to content

从第一份可回读报告开始:Open ETF Agent v0.1 ​

读完一篇 Agent 架构文章,最容易留下的问题是:这些模块放在一起,究竟怎样运行?

我们已经写过工具调用、上下文、记忆、子代理和发布系统。但如果从第一天就把它们全部装进公开工程,读者很难分清:哪一部分是完成任务必需的,哪一部分是在后来的问题里才长出来的。

所以,Open ETF Agent 从一件更小的事开始:读取两份资料,完成一次比较,把报告保存下来,并让另一个进程读回同一份文件。

v0.1 已经公开了这条工程路径。默认示例使用脚本代替模型选择动作,真实执行读取、计算与保存;它让我们检查程序怎样工作,还不能据此判断真实模型会不会读懂资料。这篇先把第一条路径讲清楚,后续再回到已有的模块文章,逐步补上对应源码。

先选一个能核对的问题 ​

示例不是个人持仓,也没有调用行情服务。我们编写了两份合成产品指南,分别描述 Atlas A、B 两个版本。

问题很简单:

免费版的导出能力有什么变化?哪些条件没有变化?把比较结果和来源一起保存。

A 支持 CSV,B 增加 Markdown,两者导出时都需要联网。指南同时写了企业版能力,因此“B 支持某功能”不能自动推出“B 的免费版支持该功能”。

另一道问题比较导出上限:A 每次 120 行,B 每次 300 行。这里必须保留三个不同结果:

问题结果
B 比 A 多多少?180 行/次
B 是 A 的多少倍?2.5 倍
增量相对于 A 是多少?1.5,即 150%

这些数字的作用不是证明系统会投资,而是让读者看到:原始输入、单位、分母和报告表达需要分别核对。换成金融资料,这些区分同样重要,但这个阶段不借合成产品例子声称已经完成真实投研。

本阶段只验 Runtime:参数和来源是否保真,执行或拒绝是否合法,预算、状态、部分效果与文件回执是否准确。模型把一句话说错了,可以留下观察,但不据此判 Runtime 失败,也不靠反复改 Prompt 把答案刷成“通过”。

完整问题与材料都在固定版本的 fixtures里,读者不必先准备账号或私人数据。

只留下三项核心职责 ​

这一版没有数据库、后台服务或插件系统。CLI 接收任务,循环围绕三个组件工作:

  • 模型网关(Gateway):将响应整理成“提出工具调用”或“给出最终文本”。协议没结束、参数 JSON 不完整,都不能变成可执行动作。
  • 运行时(Runtime):保留消息、配对调用与结果,检查轮数、工具额度、超时和取消。
  • 工具宿主(Tool Host):定义可用工具,验证参数,执行读取、计算和保存,并返回真实效果。

模型可以提出 read_page、calculate_change、save_report。它不能自选文件路径,也不能只在回答里写一句“报告已保存”,就让运行时产生成功回执。

这延续了“先用足够简单的组合,再按需要增加复杂度”的思路。Anthropic 在 2024 年的 Building effective agents 中也讨论过这种取舍。这里的具体选择,是让第一版只解决本地资料到报告的交接;以后新增能力,要有新的问题支持,而不预先搭一整套平台。

源码入口可以直接按执行方向阅读:cli.mjs → runtime.mjs → tools.mjs。不需要先理解几十个模块名。

“读过”需要留下具体身份 ​

read_page 返回的不只是一段正文,还包括文档标识、版本、物理页码和正文哈希。工具宿主记录本次运行实际取得的页面。

保存时,模型只能引用这个集合里的来源标识。未知来源、重复来源、模型自己拼出的文件路径,都不会成为合法保存请求。

这解决了一个很具体的问题:报告交到另一个人手里后,他能够找到当时取得的页面内容,而不是重新打开已经变化的“最新版本”。

不过,一页资料确实被读过,仍不意味着报告中的每一句话都由它支持。 v0.1 的保存检查负责来源身份,不替模型判断语义。报告是否混淆免费版和企业版、是否遗漏“必须联网”,仍是另外的阅读与内容判断。

示例中的动作和文字由透明的脚本适配器给出。它会先提出读页,再通过真实工具计算或保存;没有把预先准备的报告文件直接复制成“运行成果”。这样的脚本能稳定暴露工程接缝,但不能被当成真实模型理解材料的证据。

一个真实修复:文件已经写了,任务却失败了 ​

最早做保存路径时,我们希望报告和来源清单一起出现:report.md 保存正文,manifest.json 保存来源快照与文件回执。

独立审查构造了一个小故障:预先占住 manifest 的目标位置。运行后,报告文件确实写成了,第二步却因不能覆盖已有文件而失败。

如果只返回“工具失败”,调用方就看不到磁盘上已经发生的变化;如果返回完整成功,又会把缺失的来源清单隐藏起来。自动重试也不合适,因为第一次写入的文件仍然存在。

修复没有把两次文件写入包装成一场不存在的事务,而是让已知的部分效果一路传到 Runtime:

json
{
  "status": "failed",
  "receipt": null,
  "partialEffects": [
    {
      "callId": "save",
      "name": "save_report",
      "partialPaths": ["report.md"]
    }
  ]
}

这是测试中实际返回结果的节选:报告保留,原先占位的 manifest 不被覆盖,完整保存回执为空。调用方知道应该先查看已有文件,而不是盲目再写一次。

对应的回归用例保留了这个故障。它是本地受控实验,不是生产事故。对这个小版本来说,重要的进展是:失败结果终于能够准确描述已经发生的事情。

同一次审查还发现,拒绝符号链接祖先之前,不应先创建后代目录。当前实现先检查已有祖先,再逐层创建目录。这个保护面向单用户可信输出根,并不是对抗同一用户恶意进程的操作系统沙箱。

怎么确认读到的是刚才保存的文件 ​

一次正常运行会生成独立目录,其中有:

text
report.md
manifest.json
run.jsonl

manifest 保存来源快照,也保存报告的字节长度和 SHA-256。验证时,先让 CLI 子进程退出,再由测试读取报告原字节,与回执核对。这个读取过程不会再次调用模型,也不会重新生成一份看起来相同的答案。

这里的“独立回读”是测试实际做的事,不是 CLI 隐藏启动了一个阅读服务,也不能说明用户已经在浏览器中打开报告。JSONL 用来诊断过程,还不是能恢复执行的检查点。

这个区别决定了下一阶段应该补什么:需要浏览器阅读,就增加阅读和下载路径;需要中断续跑,再增加执行身份和恢复协议。不能因为文件已存在,就把这些能力一并算上。

在固定版本上运行 ​

以下命令面向一个新的本地目录。使用 Node 26.11.1–26.x;v0.1 没有第三方依赖。

bash
git clone https://github.com/hpoenixf/open-etf-agent.git
cd open-etf-agent
git switch --detach f9370b81beef84199293120ee23d245bb0129553

npm ci --offline --ignore-scripts --no-audit --no-fund
npm run build
npm test
npm run demo -- --case document-compare --output runs/article-v01

查看 CLI 输出中的各个 directory,就能找到报告、来源清单和诊断记录。只想看数字口径,可以运行 npm run demo -- --case T3;只关注原文件回读,可以阅读 test/cli.test.mjs 中进程退出后的校验。

这些离线命令已有固定版本的执行记录。该快照的 CLI 仍阻止 live 请求,默认示例不会偷偷调用模型。下面的真实观察来自另一个受限验证入口,不能拿来改写默认命令的性质。

接上真实模型以后:一次没有报告的观察 ​

后续验证用单独的适配器调用了原 v0.1 的 Gateway 和 Runtime,而不是把新版实现套在旧版名字下。适配器限制请求与输出,使用同一份合成资料;它不是上述固定快照的默认 CLI。过程与范围保留在验证摘要和安全记录里。

这次运行实际取得了 A、B 的第一页。两次模型请求都返回 HTTP 200,随后提出的 calculate_change 却在 Tool Host 校验时被拒绝,没有进入计算执行,也没有生成报告。运行停在 TOOL_REJECTED,没有拿“请求成功”冒充“任务完成”。

但原诊断记录没有留下被拒绝的具体参数和精确校验错误。我们因此只能确认“拒绝发生在派发前”,不能复盘那份输入究竟是否合法,更不能倒推一个听起来合理的根因。拒绝本身不是 Runtime Bug;没有派发,也不足以证明那一次拒绝完全正确。这一缺口必须保留。

后续适配器已经补上安全的提议追踪:记录必填字段是否齐全、数值是否有限、字段类型和单位形状等事实,不保存任意模型正文、凭据或请求头。这能让下一次相同路径更容易核对,但不能补回过去丢失的参数。下一步要检查的是 Host 有没有按原规则执行或拒绝,而不是重试到模型交出一份好看的报告。

这次真实运行没有验证出报告保存路径,更没有触达后续版本的 Skill、MCP、Child、Memory 或压缩。离线示例中的真实文件回读,和这次真实模型下未完成的保存,是两份不同的证据;两者都保留原样,才知道还缺什么。

接下来,沿着原来的模块文章继续 ​

v0.1 单独成篇,是为了给整个系列一个能开始动手的入口。它回答的是:最少保留哪些职责,才能把资料、一次执行和一份可回读报告连起来。

后续不必每个版本再写一篇同义架构总论。方法与工具接入回到 MCP 文章,原文外置回到结果与上下文文章,摘要和长期偏好分别回到 Main 压缩与 Memory。

这些文章会保留自己的问题和历史,再补对应版本的最小可运行实现。高级机制尚未进入公开代码时,就继续明确它的范围,不用一个相似的函数名充当完整覆盖。

希望读者每次读完,都能找到一条具体路径:运行例子,读懂关键分支,再改动它观察结果。公开工程仓库会保留这些固定阶段;如果这种从小问题逐步展开的方式对你有帮助,欢迎 Star 或提出可复现的问题。

完整英文版:Start with a Report You Can Read Back。更多文章见阅读目录与公开文集,欢迎 Star 关注后续模块与代码的对照。

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