返回系统设计 × AI 开发主线

H04 · 深入教材 · 总 Day 154174

AI 开发完整工作流

从需求到最小完整变更,深入模型与 Harness 协同调整、Provider 消息流、轻量 Trace 和仓库知识回流。

npm run learning:harness -- workflow

作者离线教学示例;模型是脚本替身,操作对象是虚拟仓库,不记录学习完成。

实践核验 · 查看来源与适用边界

查看实际源码 ↓

定位:P2 · S64~S84 的深入教材,衔接 S85~S90 整合;不是每天新增一份作业。
阅读基础:已了解 H01 的任务与上下文、H02 的状态循环、H03 的工具与变更边界。
实现边界:本章运行例子使用脚本模型和内存虚拟仓库。没有真实 LLM、网络调用、磁盘编辑、代码应用或生产级 sandbox。本文中的设计扩展均会另行标明。

实践核验:2026-09-12。 本轮补充模型与 Harness 协同调整、Provider 消息流和轻量 trace 推演。官方功能、作者经验与本仓库原创设计分开说明;来源与范围见实践核验记录

1. 先分清:用 AI 开发软件,与开发 AI 系统

这两个方向相关,但学习对象并不相同。

用 AI 开发软件,是把模型放入人的开发过程。目标软件可以完全不含 AI,例如学习网站、库存服务或账务模块。模型帮助解释代码、比较方案、生成候选修改;最终关心的是软件行为是否符合需求、改变了哪些依赖、以后能否维护。即使一次改动生成了很多代码,如果需求理解错了或者增加了隐蔽耦合,开发工作也没有因此变好。

开发 AI 系统,是把模型放进所交付系统的运行路径。例如用户每天使用的知识助手、文档抽取服务或编码 Agent。开发者不仅要处理一般接口与状态,还要处理模型输出的不确定性、上下文来源、调用成本、拒绝与失败后的行为。模型会随版本、输入和服务条件变化,因此“代码没有改”也不必然意味着应用行为不变。

开发一个 AI 编程助手同时属于两者的交叉:你可能借助模型编写它,而它交付后还会持续调用模型。这里有两个不能混在一起的循环:开发循环负责改进助手,运行循环负责完成用户任务。助手成功提出一次补丁,不能证明开发助手的架构已完备;开发者完成了类型检查,也不能证明助手理解所有真实需求。

本仓库教学 Harness 主要解释第二种系统的工程骨架,同时把第一种开发方法作为使用场景。scriptedModel 已经写死搜索、读取、提案、结束的顺序。它展示的是“系统怎样处理动作”,不是“模型怎样发现答案”。理解这个区别,才能避免把一个固定演示误写成自主软件工程能力。

2. 开发过程是一条因果链,不是几个提示词的排列

可以把一次开发理解为逐步减少不确定性的过程:

需求中的行为差异
  → 确定系统边界与不能破坏的约束
  → 找到负责该行为的数据和代码
  → 为当前判断组织足够且有来源的上下文
  → 比较候选方案并提出最小完整变更
  → 观察变更的实际行为
  → 留下决定、证据与未解决问题

每个箭头都表达原因。因为需求要求保留某个调用方的行为,设计才不能随意改变公开接口;因为设计判断依赖金额的单位,上下文就必须包含字段定义;因为已经知道根因只在一个表达式,变更才可以缩小到这一处。反过来,如果前面的依据缺失,后面再流畅的代码生成也只是快速扩大假设。

2.1 需求应表达行为差异,而不只是实现动作

“给学习页加一个按钮”描述了动作,却没有说明用户为什么需要它。“读者从课程主页能进入本阶段专题,并能返回课程目录”才表达目标行为。前者容易让 AI 只改 JSX;后者会自然引出路由、内容来源、返回导航和静态导出的设计问题。

需求也不是所有未来能力的愿望集合。若当前目标只是新增已有专题入口,就不自动包含登录、学习进度同步、推荐算法和后台编辑器。范围的作用不是限制思考,而是让建议与实际修改分开:可以记录扩展想法,但不能悄悄把它们并入本次实现。

2.2 系统设计把自然语言转成责任与约束

“专题能访问”由谁负责?内容文件提供正文;数据模块提供标识和元信息;页面解释路由并渲染;构建过程生成静态产物。它们各有责任,不能仅靠一个按钮替代。系统设计最先要回答的是责任归属,而不是先选 Agent 框架或向量数据库。

还要区分功能和约束。展示专题是功能;保留旧链接、不误写学习进度、不能把模型密钥送进客户端是约束。功能说明系统要做什么,约束决定哪些看起来可行的方案实际上不能选。当约束只藏在人的记忆里,AI 很容易给出局部合理、整体不适用的实现。

2.3 上下文是设计判断的输入,不能用长度代替充分性

假设要修改 NoteMeta.title 的展示。只给页面代码,模型可能不知道标题是否由 Markdown 一级标题解析;只给 Markdown,模型又可能遗漏搜索条目使用同一字段。足够的上下文不是整仓库,而是这一决定需要的最小关系:标题来源、类型、消费者、已有约定,以及当前任务允许改变的部分。

这可以理解为一个逐步构造的工作集合:先给目标和入口,搜索定位责任模块,再读取精确内容;发现跨层依赖时扩展集合,而不是开始就猜测全部相关文件。来源路径、内容版本和截断标记与正文同样重要,因为它们决定当前判断能否被追溯。关于这种有限上下文与按需获取信息的思路,可对照 Anthropic 的 Context Engineering;这里采用的是自编开发案例,不把该文章的经验当成普遍最优算法。

3. 最小变更不是行数最少,而是最小完整行为单元

假设系统需要新增“学习专题详情”。只增加一个静态按钮,改动行数很少,但目标路径如果没有被生成,用户仍然无法阅读。反之,把整个课程数据层重写虽然可能完成目标,却引入了许多与问题无关的不确定性。

最小完整变更同时满足两个条件:目标行为所依赖的必要连接都存在;不改变没有理由改变的其他连接。这是一种依赖上的最小性,而非字符数上的最小性。一次修改涉及五个文件并不自动说明过大;如果这五个文件分别承担内容、数据、页面、搜索和站点地图责任,它们可能恰好构成完整单元。

3.1 跨层影响分析要沿数据与行为走

以下是教学用依赖模型,不表示本章又执行了一次页面修改:

层次发生的变化下游为什么受影响
Markdown 内容新增专题正文与标题解析器需要识别正确文件,不能把说明文件当专题
数据标识增加稳定 slug 与主题元信息详情路由和导航依赖它找到同一篇内容
详情页面用 slug 取得正文未找到时应明确处理,不能展示错误专题
静态生成枚举可导出的 slug运行时能读取文件不等于发布产物包含该页面
搜索与导航增加入口页面可直接打开不等于学习者能够发现它

沿着这条链看,可以发现三种不同的“完成”:文件存在、路由可访问、用户能在正常学习路径中找到。它们不是同一件事。AI 如果只围绕当前文件工作,就容易把第一个状态误报为第三个状态。

影响分析也包含反向依赖:谁正在依赖旧字段、旧 slug 或旧默认值?例如把中文标题转拼音作为 slug,看似方便,但以后改标题可能破坏已有链接。更稳妥的设计是将稳定身份和展示文案分开。代价是多维护一个字段,收益是减少文案修改向链接系统传播的范围。

3.2 模块边界应按变化原因分开

内容作者修改正文,不应必须修改路由算法;调整卡片样式,也不应改变正文解析。按变化原因分离责任,可以让人和 AI 都更容易判断“该去哪一层改”。如果所有逻辑堆在同一个页面文件,模型可能看见更多内容,却更难识别哪一段是业务规则、哪一段只是展示。

但分层不是层数越多越好。只有一个固定函数时,为它引入仓储、服务、策略工厂和插件注册器可能徒增跳转。学习时先问:分开的两部分是否由不同原因驱动变化?是否需要独立替换?如果没有,保持简单往往更容易解释。

4. 接口与数据建模:把含混状态变成可区分的事实

AI 工作流里,一个常见错误是让单个字符串同时承担答案、工具请求、执行结果和完成证明。字符串写起来方便,但消费者必须猜它的含义。更好的接口先区分对象的职责:任务是目标,动作是请求,观察是执行层返回的事实,提案是候选变更,交接是对当前状态的说明。

本项目的 ModelPort 很小:

type ModelPort = (context: ModelContext) => Promise<unknown>

它只接收给模型的上下文,返回尚未被信任的外部结果。为什么不是直接返回 Action?因为 TypeScript 类型不能使远程服务真的遵守契约。这里保留 unknown,要求运行层通过 actionSchema 解析之后,才能把结果当成 readsearchpropose_editfinish。这一设计让可信边界在代码中可见。TypeScript 官方 Narrowing 文档 可补充判别联合与类型缩小背景;运行时 schema 校验则是本仓库额外采用的机制。

finish 是模型的一种动作,不是系统的最终事实。因此存在提案时,运行层记录 needs_review;没有提案时,记录 claimed_complete。它们都没有直接写成 completed。把状态命名清楚,会改变整个下游系统的行为:页面应该展示“有候选变更”,而不能显示“源码已修复”。

数据建模还应分开内容身份与位置。路径 src/sum.ts 表示位置,baseHash 表示读过的内容版本。只记录路径,无法知道模型基于哪个版本提出修改;只记录 hash,又不知道提案面向哪个文件。两者组合仍然不代表提案正确,但足以表达“这个建议依赖这份输入”。

若以后设计持久化记录,可以把任务 ID、动作 ID、输入版本、执行状态、结果引用分开,而不是把整段聊天存成唯一事实来源。聊天便于阅读,结构化字段便于判断重复、恢复和冲突。当前实现只有内存对象与 JSON checkpoint,没有数据库、跨进程事务或外部动作账本,不应从类型声明推断这些能力已经存在。

5. 完整案例:从一个错误表达式到可解释的修复提案

下面对照 src/learning/ai-harness/runtime.tsdemo.ts 解释真实可运行的教学例子。执行命令是:

npm run learning:harness -- workflow

5.1 输入:行为要求、源码与权限必须分开

虚拟仓库中有三份字符串:

const repo = {
  'README.md': 'sum(a, b) should return the sum of two numbers.\n',
  'src/sum.ts': 'export const sum = (a: number, b: number) => a - b;\n',
  '.env': 'SYNTHETIC_PLACEHOLDER_ONLY=not-a-real-secret\n',
}

任务目标是检查为什么 sum 在做减法,并提出最小修复,不应用修改。可读文件只有 README 和源码,可提案文件只有源码;最多六步,上下文预算为 4000 个 JavaScript 字符串长度单位。这个预算不是 token 数,.env 也不是真实秘密。权限由任务白名单给出,不由仓库文本或模型输出改变。

从语义上,需求是“两个数相加”;当前表达式是 a - b。例如手工代入 a=7, b=2,现有表达式得到 5,目标行为应得到 9。这里是对表达式的纸面推导,不是教学 Harness 已编译并运行了被修改源码。它足以解释候选修改的动机,却不等于覆盖所有 JavaScript 数值边界。

5.2 初始状态:没有读取过文件,不应允许直接编辑

初始 steps=0proposals=[]readHashes={},观察为 No file has been read yet.。模型看到任务、可读可改文件索引、工具名和最近观察,看不到整个虚拟仓库。运行层保存的事件列表也不会原封不动塞进上下文。

此时若模型直接提出编辑,即使碰巧猜对 a - b,也缺少“读过匹配版本”的前提。系统要求先读,不是形式主义,而是在建立提案与输入证据的关系。真实项目中文件很可能刚被别人修改,靠记忆或猜测生成的 patch 缺乏可靠基线。

5.3 第一步:搜索提供定位线索,而不是完整理解

脚本模型在 context.step===0 时返回:

{ "type": "search", "query": "sum" }

运行层只搜索允许读取的路径,得到 README 的行为说明和源码的函数定义,两者都在第 1 行。结果标成 untrusted_search_result,包含路径、行号和片段。状态变为 steps=1,记录一条 search 事件,但仍没有源码的已读版本。

这里可以看到两种证据的作用:README 说明意图,源码说明当前实现。两者发生矛盾才形成修复假设。搜索结果不是编译器分析,也不是代码执行;它只是按行做字符串包含匹配。换成符号别名或跨文件间接调用,可能需要其他定位方法。

5.4 第二步:读取建立版本绑定

模型返回 read(src/sum.ts) 后,运行层返回完整的短源码、truncated:false 以及内容指纹,并把指纹记入 readHashes。本例当前内容的指纹是:

80e0d345974390ff50c413e27e1916970e25724000b181c1725f342a5849fdd9

为便于解释,下文把它记作 H;H 只是文中的简称,真实动作必须携带完整 64 字符串。状态现在是 steps=2,无提案,但 readHashes['src/sum.ts']=H。下一次上下文中的最近观察就是这次读取结果,脚本模型会从中取出 baseHash

读取的价值不仅是“看到代码”,还在于把后续修改钉在一个确定版本上。本例的指纹是内容身份,不是作者签名或防篡改认证;不要把 hash 当成安全审计结论。

5.5 第三步:提出最小变更,保留原文件

模型提出的动作相当于:

{
  type: 'propose_edit',
  path: 'src/sum.ts',
  baseHash: H, // 文中简称;真实运行使用上面的完整指纹
  before: 'a - b',
  after: 'a + b',
}

运行层依次确认:路径可改;这个版本已被读取;尚无同文件提案;当前文件仍匹配该版本;替换目标恰好出现一次;新旧片段确实不同。通过后只生成 preview,不把它写回 repo

于是 steps=3proposals.length=1,预览为:

export const sum = (a: number, b: number) => a + b;

这是最小完整变更的简单情况:公开函数名、参数与返回方式均不改变,只把与意图矛盾的表达式换掉。没有理由顺便加日志、改目录、换依赖或扩展成可变参数函数。复杂项目不一定只改一行,但每一处改变都应能回到同一条行为解释。

5.6 第四步:结束提案过程,不冒充完成开发

模型发出 finish,说明一处表达式修改已提出、尚未应用和检查。运行层看到已有提案,将状态设为 needs_review。运行输出的关键字段是:

{
  "status": "needs_review",
  "stepsUsed": 4,
  "proposedFiles": ["src/sum.ts"],
  "virtualRepoUnchanged": true,
  "proposedSource": "export const sum = (a: number, b: number) => a + b;\n"
}

这些字段在实际结果中分别来自 state、handoff 和 workflow 外层;此处是合并后的阅读摘要,不是完整 JSON 的原样复制。事件顺序为 search、read、propose_edit、finish。最后交接仍列出真实模型行为、候选源码编译、候选源码行为与 OS sandboxing 未验证。

因而可以准确说“教学运行层生成了基于已读版本的候选源码”;不能说“AI 独立定位并修好了真实仓库”。更不能因为手算 7+2=9,就声称已验证数值精度、NaN、Infinity 或调用方行为。本次作者运行可以核对教学程序的输出,不会自动填入学习者的完成进度。

6. 出错后怎样归因:先看证据在哪一层断了

“模型不够聪明”往往过于笼统。同一表面失败可能来自不同机制,调整方法因此完全不同。

**模型没有拿到关键信息。**例如只提供函数签名而没有行为要求,它无法可靠判断减法究竟是 bug 还是命名不准。先修上下文:补充需求来源与相关调用,不急于更换模型。若文件超过读取上限,关键表达式被截断,问题在工具可见性;重复要求“认真读”不会使缺失字节出现。

**信息有了,但动作无法表达意图。**本例只支持一个唯一片段替换。真实改动可能需要同文件连续修改、移动符号或跨文件原子变更,模型却只有受限动作。此时拒绝可能说明接口能力不足,而不是推理错误。应先讨论是否扩大任务与工具能力,不能把多个不兼容操作硬塞进字符串字段。

**动作合法,但模型的业务判断错误。**假设需求明确要求相加,版本也正确,模型却建议改成乘法。schema、白名单和 hash 都可能通过,因为它们不理解业务意图。这才更接近语义推理或目标遵循问题,需要比较模型获得的证据、采用的解释和候选方案,而不是继续加路径限制。

**运行层自己弄错状态。**例如把工具失败当成成功观察、恢复后把步数清零、把提案误标为已应用,模型即使每次判断合理也会在错误世界模型上工作。本项目通过明确状态和事件让这些问题可见,但未实现所有可靠性机制。尤其 ModelPort 抛出的异常统一变为 model_error,没有区分服务不可用、配额限制、取消和适配解析错误;该状态不能直接用于精确诊断供应商问题。

因此错误归因应从“目标证据 → 可见信息 → 动作表达 → 执行结果 → 状态更新”逐段核对。一次轨迹只能给当前案例的解释,不能证明整个系统的总体可靠性。学习阶段看清一条失败的机制,比统计很多没有解释的总分更有帮助。

7. 怎样调整:上下文、工具、模型不是同一个旋钮

可以固定同一个小任务,依次改变一个因素。不是为了建评分平台,而是避免所有东西同时变化后不知道哪一项起作用。

先看信息缺口。如果模型找错文件,补充入口、符号来源和一次精准搜索;如果混淆旧新需求,提供版本与生效范围;如果最近观察被无关日志淹没,缩小工具返回,并保留可重新读取的来源。摘要减少阅读量,但也可能丢失决定性条件,因此摘要不能无条件取代原文。

再看交互成本。若每次都要读整个大文件才能找一个函数,可考虑片段读取;若纯文本搜索经常返回同名符号,可设计带模块和符号身份的查询。工具应减少模型为表达意图付出的额外工作,而不是增加一排名字相近的接口。本例没有这些工具,正文是在解释扩展方向。

模型选择也应进入判断,而不是永远排在最后。对于证据充分但组合推理困难的任务,更强的推理能力可能有帮助;对大量格式转换,清楚的 schema 和例子可能更直接。上面的顺序是便于初学者定位信息与接口问题的启发式,不是“必须先把旧 Harness 调到极限才能换模型”的定律。换模型还可能改变延迟、成本和输出习惯,不能只依据一次漂亮回答作决定。这里不预设某个供应商或型号,也没有真实模型对比结果。

并行与多 Agent 同样是系统选择,不是默认升级路线。两个独立文档模块可以并行研究,但两个人工或模型执行者同时编辑同一接口,会增加冲突、过期上下文和汇总成本。共享目标不代表共享状态天然一致。Building Effective Agents 的 workflow 与 agent 区分可用于思考控制结构;当前程序仍是单个 model port,未实现多 Agent 编排。

7.1 为什么模型变了,Harness 也需要重新检查

可以用一个概念关系帮助思考:观察到的任务结果 = 模型能力、上下文质量、工具表达、运行策略和环境条件共同作用的结果。这里不是可拟合的数学公式,而是提醒:这些因素有交互,不能把结果全归给其中一个。

例如某个较早模型在长任务中容易提前收尾,开发者为它加入频繁重启与交接。后来模型改善了长任务连续性,原来的重启仍会打断工作、消耗交接信息,却未必再提供收益。Anthropic 在 2026-03-24 的长时应用开发 Harness 经验中报告过随模型变化取消 context reset 的具体案例。这是特定模型与任务的观察,不是“以后所有系统都应取消恢复和交接”的证据。

由此可得的设计启发是:把必须守住的约束帮助某个模型工作的策略分开。权限、用户目标、已执行副作用的记录属于前者;“先搜索三次”“每二十步重启”“任何任务都用三个 Agent”属于后者。改模型时可以重新审视后者,不能把前者也当成旧提示词一并删掉。这个区分比记住某一个框架的默认参数更容易迁移。

7.2 目标与约束要明确,过程细节按需要给

以“新增教材来源入口”为原创设计案例。过度固定的指令可能是:必须先打开五个指定文件、生成十步计划、建立三个角色、跑所有检查,再改按钮。它的问题不是字数长,而是把作者猜测的路径固定下来:若现有数据模块已经统一路由,这些动作可能没有提供新证据。

更清楚的任务表达可以是:读者能从 Harness 首页和每章找到资料核验记录;沿用现有内容与渲染结构;保留四章和九十天日课映射;不改变学习进度;修改后确认链接指向实际导出页面。模型仍需要发现实现位置,但不必执行与目标无关的流程。与此同时,“修改前读取目标文件”“不得上传私有内容”等确有作用的边界仍然明确。

这不意味着永远只写一句目标。业务术语含混时需要例子,接口有罕见规则时需要明确字段,模型持续犯同一错误时需要有针对性的过程提示。OpenAI 当前的模型指南也对指令敏感性、委派和检查强度分别给出模型相关的调节建议。教材只采纳“重新检查指令与行为是否匹配”这一思路,不复制某个型号的全部提示词或把它们当成跨模型标准。

7.3 小范围比较怎样帮助理解,而不变成评分工程

保留同一个任务和输入快照,先观察一个明确卡点:遗漏路由、找错文件,还是正确找到了文件却误解业务。只改变与卡点有关的一项,例如补一条目录关系,再看下一次轨迹是否真的使用了它。若同时换了模型、增加检索、放宽工具和改写需求,只能说整个组合不同,不能判断是谁起了作用。

模型升级时还可以把“新模型 + 原策略”和“新模型 + 简化策略”作为两个小型候选。比较的目的不是排名,而是理解旧策略是否仍有必要。一次示例成功只能说明该例可行;任务偶然简单、模型随机性和工具环境差异都可能影响结果。没有真实运行数据时,像本文一样明确写“设计假设”,比编造成功率更有学习价值。

8. 接入真实 Provider:先定义适配责任,不在教材中偷偷调用

真实模型适配器的职责,是把内部 ModelContext 转换为所选服务的请求,再把响应转换成内部动作。它不应承担文件权限判断,也不能绕过 Harness 直接执行供应商返回的工具调用。保持这个边界,才能在替换模型时尽量不改工具执行与状态逻辑。

例如同一次输入包含 objectivestep=2 和读取观察。适配器可以将固定 instruction 映射为服务支持的高优先级指令,将文件内容保留为不可信工具数据,提供四种动作的结构定义。服务返回一个工具调用后,适配器提取参数对象,运行层仍用 actionSchema 重新解析。若返回纯文本、多次调用或拒绝,必须有明确的映射策略;不能把任何文本都包装成 finish,那会把无法执行伪装成完成建议。

以下是设计草图,不是现有实现,也不是可以直接调用的 SDK 代码

type ProviderOutcome =
  | { kind: 'action'; rawAction: unknown; usage: Usage | null }
  | { kind: 'refusal'; message: string; usage: Usage | null }
  | { kind: 'failure'; reason: 'timeout' | 'unavailable' | 'invalid_response' }

type Usage = { inputTokens: number | null; outputTokens: number | null }

草图把动作、拒绝与传输失败分开,因为它们需要不同处理:拒绝不应无限重试;临时不可用可能值得有限等待;无效响应需要保留原因但不能执行。usage 或其中字段为 null 表示未观测,不等于消耗为零;这个最小草图未列出缓存等全部计量项。当前 ModelPort 返回 unknown 且异常被统一捕获,如果采用这一草图,需同时演进端口、状态与交接,不能只换一个 HTTP 函数就宣称完成集成。

资源控制也要区分层次。maxSteps 限制循环次数,但一个模型调用永远不返回时,它并不能终止等待。真实适配需要超时与取消信号;还要知道取消本地等待不一定意味着远端未执行或未计费。重试之前要区分模型请求与有副作用工具:多请求一次模型主要影响费用与候选结果,重复应用文件变更可能破坏状态,不能共用一个无差别重试器。

成本来自实际计量的输入输出和所选服务的计费规则,不能用本例字符串长度替代。设计时应先设请求上限和总预算,再记录实际 usage;服务报告迟到或不可用时,还需要保守的预算处理。模型标识、配置版本和适配器版本也应加入恢复条件;当前 signature 只绑定任务、虚拟仓库与教学 Harness 版本,没有包含真实模型配置。

数据范围必须先明确:允许发哪些文件、是否可以离开本机、保存多久、谁能看到日志。密钥来自可信的服务端或本机配置,不应进入网页、上下文或演示输出。本章没有读取 API Key、没有选择付费服务、没有上传代码;真正接入需要另行选择服务与授权数据范围。这是未实现能力的边界,不是留给学习者猜测的隐藏步骤。

8.1 先决定谁拥有循环,再选择 SDK

直接模型 API、Agent SDK 和托管 Agent 服务并不是同一个东西的三种写法。关键问题是:谁保存会话,谁决定何时再调用模型,谁运行工具,出错后由谁恢复。OpenAI 的运行方式说明分别描述 Responses API、应用内 Agents SDK 与托管 Agents API;下面是对本仓库选择的原创分析,不是产品完整对比。

接入形态对本课程的价值需要避免的责任混淆
自己调用模型 API能直接看清模型请求、工具请求与状态提交的关系API 返回了内容,不等于应用已经执行工具或持久化状态
应用内 Agent SDK复用循环、工具和交接支持,把精力放在业务能力SDK 内部已有循环时,不要无意再套一套互相竞争的恢复循环
托管 Agent 服务研究远端运行、会话与执行环境的责任接口服务会话、源码工作区和本地任务记录的生命周期仍然不同

当前教学代码故意自己控制 while 循环。若未来只新增远程 ModelPort,应保持工具执行仍归现有运行层;若改用拥有整套循环的 SDK,则需要明确替换哪一层,而不是让两个循环各自认为自己拥有预算与停止权。选工具之前画出这几个所有者,就已经完成了一项重要系统设计工作。

8.2 把一次 read 走完:线上协议如何映射到内部动作

以下是未发出的协议示意,不是抓到的网络记录,也不是可直接执行的完整请求。假设应用选用 Responses function calling,把内部 read(path) 暴露为 read_file。模型返回的某一个 output item 可能形如:

{
  "type": "function_call",
  "call_id": "call_read_demo_01",
  "name": "read_file",
  "arguments": "{\"path\":\"src/sum.ts\"}"
}

适配层先识别 item 类型和工具名,解析 JSON 参数,再转换成内部的 { type: 'read', path: 'src/sum.ts' }。运行层重新检查 schema、当前任务的可读路径及文件状态。call_id 用来匹配这一次协议调用,不能替代可读权限,也不是文件版本。

通过后,执行层获得正文与版本 H,并更新自己的 readHashes。适配层把观察放回结果项,例如:

{
  "type": "function_call_output",
  "call_id": "call_read_demo_01",
  "output": "{\"kind\":\"untrusted_file\",\"path\":\"src/sum.ts\",\"baseHash\":\"<完整内容指纹 H>\",\"content\":\"export const sum = (a: number, b: number) => a - b;\\n\",\"truncated\":false}"
}

H 在这里是占位说明,不是有效 hash。output 中的 JSON 是应用自定义的观察格式;API 不会因为它出现 untrusted_file 字样就自动建立操作系统权限。具体的 function_call、参数与结果关联语义依据 Function calling 官方文档。发送续接请求时还要按所选会话模式保留必要输出项或引用前次 response,不能只抄最后一句答案而丢掉调用关系;原生 reasoning/compaction 等项也不应随意转成普通聊天文本。

如果服务返回两个工具调用,当前单动作端口没有表达这种批次的能力;适配器应明确限制为受支持的单动作,或者演进端口与调度器,不能静默丢弃其中一个。拒绝、未完整生成和普通解释文本同理,都需要独立处理。真实接入的工作量主要在这些语义对接,而不仅是让 HTTP 返回 200。

这里还暴露出现有接口的教学简化:ModelPort(context) 没有明确的 provider 会话 ID、调用 ID 或完整 output item 历史。若引入有状态续接,相关协议状态必须有清楚的保存、恢复和失效规则;单纯在函数闭包里藏一个 previous_response_id,进程重启后就可能丢失对应关系。H02 的运行状态与 H01 的上下文压缩因此会在这里相遇。

8.3 配置也是行为的一部分,未知 usage 不能记成零

一次运行至少需要知道采用了哪个模型标识、哪一版工具契约、哪一种上下文策略和适配器版本。如果 SDK 更新后默认模型变化,而记录里只有“用了 AI”,以后无法解释差异。OpenAI 的Models and providers建议显式选择模型,并说明特性依赖具体模型与接入路径;本课程据此保留“模型配置是运行契约的一部分”的原则,不绑定默认型号。

同样,usage 字段缺失应表示未知,而不是自动填 0。网络中断后可能已经生成部分内容;缓存读取、缓存写入和普通输入的计费口径也可能不同。学习时不用先搭计费系统,只需理解两个数:请求前允许的预算,和请求后实际报告的消耗。两者之间有延迟与未知,不能互相冒充。

9. 反馈与知识回流:把一次经验变成下次可用的信息

一次任务结束后,最有价值的不是重复保存整段聊天,而是解释“为什么卡住、哪一个机制改变了结果、这个结论适用于哪里”。例如模型多次找不到专题页,不一定要写“以后更加仔细”;可以记录“入口来自数据模块的 slug 枚举,修改内容目录时同时检查该枚举”。后者把模糊提醒转成可操作的仓库知识。

知识应写到对应层次。长期稳定的目录约定进入仓库导航;局部设计取舍进入简短决策说明;某次任务的未完成项进入 handoff;只有这次有效的临时路径不应变成永久规则。如果把所有经验都累积到一个巨型提示词,后来的任务会承担无关信息成本,过期规则还可能互相冲突。

一个轻量记录可以只有三段:事实——哪条搜索或观察显示了问题;解释——为什么当前信息或接口不足;改变——下一次补哪一条来源或改哪一种工具。再加一句适用边界,例如“只观察过本地静态页面任务,不代表后端发布流程”。这样的反馈既能帮助人复习,也能帮助以后构造更准确的模型上下文。

人的学习完成与教材准备仍是两本不同的账。阅读本文、运行作者示例和独立完成真实任务是不同经历,不需要伪装成同一种成绩。你可以只取一个案例,亲自解释四步状态为什么变化;也可以在熟悉的页面变更中复用跨层分析。轻量学习不是放弃原理,而是少做重复形式、多理解一个可迁移机制。

9.1 Trace 记录因果关系,不需要记录模型的内心独白

普通日志是某个位置发生的消息;trace 把同一次任务中的模型调用、工具调用与结果用标识关联起来;span 表达其中一个有开始与结束的操作。这里关注可观察行为:给了什么范围的信息、请求了什么动作、执行器返回什么、任务状态为何变化。不需要也不应该把模型未提供的内部推理补写成事实。

当前 events 只有 step/kind/detail,足够解释单线程教学顺序,却没有持续时间、父子 span、provider request ID 和真实 usage。因此它是简化事件记录,不是已经接入可观测平台。OpenAI 的SDK 观测说明展示了模型调用、工具调用和交接的结构化 trace;使用 SDK 时还要检查默认导出行为及数据范围。本课程没有安装或启用这个 SDK,也不上传学习仓库。

一个适合纸面推演的最小设计如下,字段是本教材自定义,不是 OpenTelemetry 或厂商 SDK 的标准 schema:

{
  "runId": "demo-run",
  "actionId": "demo-run:read:1",
  "taskRevision": 1,
  "stage": "tool_result",
  "tool": "read",
  "inputRef": "src/sum.ts@H",
  "outcome": "ok",
  "durationMs": null,
  "usage": null,
  "effectApplied": false
}

为什么保存引用而不是整份文件?诊断常常只需要知道来源和版本;全文可能包含业务数据,复制到每条日志会扩大暴露面。需要细查时,可在授权范围内沿引用读取原记录。hash 本身也不是匿名化保证,敏感内容和低熵值仍需控制访问。null 明确表示没有观测耗时与模型计量;这个例子不支持计算真实成本、延迟或缓存命中率。

9.2 同一个案例,怎样区分可证事实与设计猜想

本地 workflow 的可证事件是:搜索、读取、提出修改、结束提案,共四步;终态是 needs_review,虚拟仓库未变。若只看最后一句“已提出修复”,会漏掉它依赖哪一版源码,也容易误读成文件已改。把事件与状态放在一起,证据才完整。

再做一个假设的真实模型故障:模型在收到大量搜索结果后重复找同一个文件。先查看实际送入模型的上下文,而不是运行层完整内存;如果其中只有被截断的 JSON,优先解释信息传递失败。候选改进是返回合法的短结果对象、记录 truncated 和可继续读取的引用。改进后应观察模型是否使用了引用、是否取得缺失片段;不能只因输出更短就宣布效果更好。当前脚本模型不会自主处理这种情况,本节没有声称它已通过该实验。

这个过程形成一个轻量闭环:一条具体观察 → 一个可解释原因 → 一处针对性改变 → 看对应行为。若改后仍重复,原假设就可能不成立,下一步检查查询表达或模型决策。无需先制定一百道题,也不需要把一次运行包装成总体成功率。

9.3 仓库知识要能发现、能核对、也能失效

把“专题数据位于哪个文件、路由从哪里枚举”写成短导航,比把所有页面正文复制到常驻指令更适合复用。稳定约束写在最容易找到的位置,较长机制解释链接到专题,任务特定事实留在 handoff。这样下一次可以按问题加载资料,呼应 H01 的渐进加载。

知识回流还需要失效意识。记录“路由当前由某模块枚举”时,最好附路径和适用范围;未来重构后应更新这条关系,而不是追加一条互相冲突的提醒。删除过期策略与删掉历史学习资产不同:原笔记可以保留为背景,新入口应明确当前做法。模型能读取仓库,不代表它自动知道哪份文档仍然有效,这也是工程师需要补齐的信息架构能力。

10. 从开发工作流走向后续研究

本章的核心认识是:有效的 AI 开发来自目标、证据、上下文、工具和人的判断共同作用。修改 Harness 后结果变好,并不自动表示模型学会了新知识;可能只是它终于看到了正确文件,或者不再丢失状态。这为 P3 研究规划、记忆与泛化提供了一个重要区分:系统提供的帮助与模型本身的能力不是同一个变量。

到具身智能时,工具调用变成环境动作,版本过期可能对应过时观测,恢复也可能面对已经发生且不可逆的物理结果。软件中“先生成提案、以后应用”的做法能启发责任分离,却不能直接充当机器人安全机制。本阶段继续停留在软件教学与纸面推导,不将例子外推为真机能力。

继续阅读时,可用 FSDL 2022 课程 的开发、数据管理、部署与持续学习内容补全生命周期视角。它的用途是帮助理解局部代码为什么处在更长的系统链路中,而不是要求再完整完成一套外部作业。

按需查阅支撑知识

展开对应 21 篇日课(选读,不是额外作业)

真实实现 · demo.ts

src/learning/ai-harness/demo.ts · H01~H03 共同阅读 runtime,H04 阅读 demo;文件指纹复用 W02 的教学函数。

import { fingerprint } from '../ai-systems/w02-release-bundle'
import { buildContext, initialState, runHarness, type ModelPort, type Task, type VirtualRepo } from './runtime'

export const repo: VirtualRepo = {
  'src/sum.ts': 'export const sum = (a: number, b: number) => a - b;\n',
  'README.md': 'sum(a, b) should return the sum of two numbers.\n',
  '.env': 'SYNTHETIC_PLACEHOLDER_ONLY=not-a-real-secret\n',
}
export const task: Task = { objective: 'Inspect why sum subtracts and propose a minimal fix. Do not apply changes.', readPaths: ['README.md', 'src/sum.ts'], editPaths: ['src/sum.ts'], maxSteps: 6, contextChars: 4000 }

// Scripted model stand-in: demonstrates the port and runtime, not AI competence.
export const scriptedModel: ModelPort = async context => {
  if (context.step === 0) return { type: 'search', query: 'sum' }
  if (context.step === 1) return { type: 'read', path: 'src/sum.ts' }
  if (context.step === 2) {
    const observation = JSON.parse(context.lastObservation) as { baseHash: string }
    return { type: 'propose_edit', path: 'src/sum.ts', baseHash: observation.baseHash, before: 'a - b', after: 'a + b' }
  }
  return { type: 'finish', summary: 'A one-expression edit is proposed. It has not been applied, compiled, or behavior-checked.' }
}

export async function demo(name: string) {
  if (name === 'context') {
    const context = buildContext(task, initialState(task, repo))
    return { context, usedChars: JSON.stringify(context).length, contextBudget: task.contextChars, excludedFromModelIndex: ['.env'], boundary: 'Exact path allowlist over in-memory files; not a filesystem sandbox.' }
  }
  if (name === 'resume') {
    const paused = await runHarness(task, repo, scriptedModel, { pauseAfter: 2 })
    const resumed = await runHarness(task, repo, scriptedModel, { checkpoint: paused.checkpoint! })
    let staleSnapshot: string
    try { await runHarness(task, { ...repo, 'README.md': 'Changed requirements' }, scriptedModel, { checkpoint: paused.checkpoint! }); staleSnapshot = 'unexpectedly resumed' }
    catch (error) { staleSnapshot = error instanceof Error ? error.message : String(error) }
    return { paused, resumed, staleSnapshot }
  }
  if (name === 'boundaries') {
    return {
      disallowedRead: (await runHarness(task, repo, async () => ({ type: 'read', path: '.env' }))).handoff,
      unsupportedShell: (await runHarness(task, repo, async () => ({ type: 'shell', command: 'echo unsafe' }))).handoff,
      stalePatch: (await runHarness(task, repo, async context => context.step === 0 ? { type: 'read', path: 'src/sum.ts' } : { type: 'propose_edit', path: 'src/sum.ts', baseHash: fingerprint('old source'), before: 'a - b', after: 'a + b' })).handoff,
      stepBudget: (await runHarness({ ...task, maxSteps: 2 }, repo, async () => ({ type: 'search', query: 'sum' }))).handoff,
      contextBudget: (await runHarness({ ...task, contextChars: 200 }, repo, scriptedModel)).handoff,
    }
  }
  if (name === 'workflow') {
    const before = fingerprint({ ...repo })
    const result = await runHarness(task, repo, scriptedModel)
    return { ...result, virtualRepoUnchanged: before === fingerprint({ ...repo }), proposedSource: result.state.proposals[0]?.preview }
  }
  throw new Error(`Unknown demo: ${name}`)
}