搜索文章

输入关键词开始搜索

我们到底在做什么:AI视频客户端其实是一套Harness

AI#AI#Agent

我们到底在做什么:AI视频客户端其实是一套Harness

最近在重新拆 AI 视频项目的任务,有两件事让我停下来想一个更基础的问题:我们到底在做什么。

第一件是客户端开发走偏。原本的目标是在 OpenCode 上扩展能力,最后做出来的东西却越来越像重新写了一个客户端,OpenCode 已有的模型切换、Session、Provider 等能力反而没有保留下来。

第二件发生在给多个 AI 分配开发任务的时候。有一个 AI 花了大量时间运行测试、整理报告和补验收证据,但真正的功能几乎没有推进。

这两个问题当时看起来分别是任务拆解问题和执行问题。我一开始也是这么处理的:前者归因为边界没讲清楚,后者归因为任务没拆好。后来看到 Lilian Weng 写的 Harness Engineering for Self-Improvement,才发现它们指向同一个更大的问题:我们对这个项目的定位本身就错了。

这篇文章就是把这次重新理解的过程记下来。

重新定位:从 Pipeline 到 Harness

一开始我对这个项目的理解接近一条 Pipeline:

输入需求
  → 生成脚本
  → 生成图片
  → 生成音频
  → 合成视频

如果流程始终固定、每一步都能稳定执行,这样设计没有问题。但大模型不是普通函数,它的输出不完全确定,可能缺信息,可能选错工具,也可能在中间某个阶段失败。视频生产又是一条长流程,任何一步出错都不能简单地从头重跑。

从用户角度看,他要的也不是“调用一次大模型”,而是把创作意图和已有素材,稳定地转化成一个可控制、可评估、可恢复、可以最终交付的视频。

单独一个模型保证不了这个结果。它至少还需要:当前任务需要哪些上下文、应该用哪个 Skill 和 Tool、每一步按什么顺序执行、哪些操作必须用户确认、中间结果存在哪里、失败后从哪里继续、怎样判断结果达标、哪些阶段可以局部重做、最终产物之间是什么关系。

负责组织这些东西的系统,就是 Harness。它处在模型和真实业务环境之间:模型负责理解、判断和生成,Harness 负责让模型在一个可控制的环境里完成工作。

这里做个记录:这次定位修正直接影响后面所有拆分决策,所以先把它写清楚,再往下谈客户端、产品面和边界。

按这个定位重新看,整套系统应该分成四层:

用户体验层

叙事创作 Harness

媒体生产 Runtime

持久化服务平台

OpenCode 本身可以理解为一个通用 Agent Harness,它已经提供了模型、Provider、Session、对话、权限和工具调用这些通用能力。我们要做的不是替代它,而是在它之上加一层领域化的 Narrative Harness:理解叙事创作目标、组织上下文、注册和选择 Narrative Skill、管理工作流、保存 Manifest、控制确认和导出、评估结果并决定是否重试。前面那次“重写 OpenCode”的走偏,按这个框架看就清楚了:该加的是领域层,通用层不该动。

再往下,图片生成、TTS、视频渲染、FFmpeg 这些是真正干活的执行器,属于生产 Runtime,它们本身不是 Harness。服务端也必须保留,因为批量生产、其他系统调用 API、定时任务、失败重试这些场景,不可能依赖某一台个人电脑持续在线。

所以客户端和服务端不应该各自实现一套生产逻辑,两种入口共用同一层就够了:

创作者                         业务系统 API
    │                              │
OpenCode Desktop              Headless Driver
    └──────────────┬───────────────┘

          Narrative Harness

        本地或服务端生产 Runtime

客户端和 API 只是两种不同的入口,后面的工作流合同、状态结构、Skill 语义和评估标准应该尽量一致。否则时间一长,我们大概率会得到两套行为不同、无法互相复现的系统。

概念上我现在这样区分:Tool 是确定性的执行原子,Skill 是使用这些 Tool 的领域操作说明。Workflow 管的是带状态、依赖、重试和确认点的生产过程,Agent 负责理解目标、选择 Skill 和处理异常。Harness 则把这些组织成一个能长期跑的系统。之前我们倾向把服务端能力尽量包成 Skill 装进客户端,方向没有错,但 Skill 只是 Harness 的扩展模块。一个 SKILL.md 能告诉模型什么时候用某个能力、怎么处理失败,但稳定的产品还需要明确的 Tool Schema、状态机、Manifest、权限和测试合同——把 Python 函数外面包一层描述,产品质量不会自动复制过来。

第二个事故也能从这个角度解释。我们真正想优化的是用户可见的功能,但任务里最明确、最容易被 AI 识别的奖励是测试报告和验收证据,于是 AI 优化了验收活动,而不是产品结果。这并不代表测试没有价值,问题出在反馈信号设计错了。后面的开发任务应该按这个顺序走:先跑通最小用户结果,提交真实功能代码,运行针对性测试,生成可检查的产物,最后才执行完整门禁。测试必须保留,但“运行了很多测试”不能替代“功能已经可以使用”。

客户端围绕 Artifact 和 Manifest 建模

第二个要重新想的是客户端本身。拆页面时我一开始也按常见 AI 产品来想:左边历史会话,中间聊天框,Agent 生成脚本、图片、视频以后把链接发在消息里,用户不满意就再说一句“第三张图重做”。

这个交互做 Demo 很顺。但把完整生产流程摆出来,问题马上出现:一段脚本有多个版本,一组分镜对应多张图片,音频和视频依赖前面的选择。用户到底批准了哪一版?改了一张图哪些内容要重新生成?这些信息埋在聊天记录里,系统和用户都会慢慢失去判断。

聊天框适合表达意图,不适合保存状态。用户描述目标、Agent 解释计划、系统询问缺失信息,这些都是过程性的。Agent 回复一句“已经生成完成”,不能证明文件存在、能够播放,也不能证明它用了正确的输入。聊天记录本身也不可靠:用户可能删掉 Session,模型上下文压缩后只剩摘要。如果一个本地 Run 必须依赖某条历史消息才能恢复,那它其实没有恢复能力。

所以脚本、分镜、图片、音频、视频都应该建模为 Artifact,而不是消息附件。每个 Artifact 至少要知道:类型和状态、版本和内容 hash、由哪个 Skill 和模型生成、依赖哪些上游、当前是否被选定。有了这些,用户说“把第三个镜头的图换掉”时,系统能定位到具体 shot 和版本,只让真正依赖它的下游失效,其他图片的 hash 保持不变。做到这一步,局部修改才是真的局部。

Manifest 是运行真相,必须独立于 Session:记录 Run ID、每次 attempt 的幂等键与状态、Artifact 的版本、相对路径、内容 hash、lineage 和最近可恢复 checkpoint。目录名是给人看的,运行时不能靠“这个文件好像叫 final.mp4”来推断它就是当前成片。

Manifest 也不是普通日志,半写入或者被错误路径污染,恢复时比没有状态更危险。所以有几件麻烦但必要的事:路径只能是 Workspace 内的相对路径(绝对路径换台机器就失效,发到服务端还暴露机器信息);写入先落临时文件再原子替换;登记前先计算 hash;未知 Schema 直接拒绝。Session 可以丢,已确认的项目状态不能丢。

能力具体沉到哪里,我把它拆成四层,各管一件事:

Workflow Skill
  → Typed Tool
    → Capability Package(可测试的脚本/能力包)
      → Workspace Artifact + Manifest

Skill 管“这件事应该怎么做”:目标理解、阶段顺序、确认点和失败策略。Typed Tool 管“系统允许做什么”:明确的输入输出 Schema、权限、超时、幂等键和稳定错误码,Agent 失败后应该根据 PERMISSION_DENIEDVERSION_CONFLICT 这类错误决定下一步,而不是解析一段英文报错去猜。Capability Package 管确定性执行,接受结构化输入、产生结构化结果,不启动 Agent 也能跑 unit test。Manifest 管“实际发生了什么”。每层各有一条禁止项:Skill 不持久化业务真相,Tool 不开放任意 shell 和任意文件访问,Capability 不决定权限。以前所有问题都表现为“Agent 没做好”,分层以后才能找到真正的负责人。

拆页面的过程中我还一直在想另一个问题:如果明天换掉当前的客户端底座,今天写下的东西还能剩多少?如果主要能力都藏在 React 组件、按钮回调和某个 Agent Runtime 的内部接口里,壳一换,产品也差不多要重做。举个例子,用户把画面比例从横屏改成竖屏,这不只是一个 Select 值变了,它可能影响分镜布局、图片生成参数、渲染画布和最终预览。规则只写在某个页面的 onChange 里,服务端和另一个客户端就根本不知道还要重算什么。

所以真正要沉淀的是页面之下的领域合同:一个需求经过哪些阶段,每个阶段需要什么输入、产生什么结果,哪些结果必须用户确认,修改一张图后哪些下游失效,失败从哪里恢复。暂时不能把实现抽成共享核心时,至少要共享输入输出 Schema、固定 fixture、确定性 validator 和质量评测。这里有个合理的反方:为了可迁移而抽象过度会拖慢第一版。这个担心是对的——不需要先做 Workflow DSL 或通用 Agent 框架,围绕当前完整流程把重复出现的合同、工具和评测提出来就够了。

页面也要给不同 Artifact 合适的工具:脚本需要长文本编辑和版本比较,图片需要并排预览和选版,音频需要试听,视频需要原生播放和进度定位,这些很难靠一串 Markdown 链接完成。比较合适的首版布局是:对话区负责目标、计划和确认,Artifact 工作区负责查看、编辑、比较、批准和重做。不用一上来做专业时间线和无限画布,先把版本、选择、预览和 lineage 做对更重要。

这里我自己也有过一次反复。四层能力都沉淀好之后,我一度觉得客户端可能不那么重要了——用户直接在 OpenCode 里发一句话,Skill 调脚本把媒体依次生成出来不就行了?把真实使用过程列出来以后,这个想法站不住了:它只对开发人员成立。Skill 解决“Agent 应该怎么做”,客户端解决“普通用户如何安全、稳定地使用这些能力”,后者包括能力可发现(不能把阅读 SKILL.md 当成用户培训)、权限有边界(Tool 只能写受控 Workspace,凭据由 broker 提供短期 handle)、依赖提前检查(第一次产生媒体副作用前检查 FFmpeg、codec、字体,缺依赖时 fail closed)、出问题能给出结构化状态让支持人员复现。我后来加了一个很朴素的标准:出现问题时,用户不能被要求先学会打开终端。

同一个客户端里还会同时出现两种结果:本地运行生成的 Artifact,和提交到服务端后产生的 Durable Asset。它们可以用相同的展示组件,但必须明确标识权威来源:本地结果以 Workspace 和 Manifest 为真相,服务端任务以 Job、Asset 和 Event 为真相。

反方也要说清楚。如果任务短、输出只有一段文本、没有多版本和局部重做、也不需要审批和恢复,Chat-first 就是最简单的方案;如果用户本身就是开发人员、任务只是偶尔执行一次,OpenCode 加 Skill 加命令行可能已经足够。复杂性不是 Artifact 模型创造出来的,它本来就在生产流程里,要不要这套设计,取决于目标用户、媒体交互复杂度、权限风险和支持承诺。

产品面拆成创作端、自动化后台和服务端

第三个问题是产品形态。最初的想法是做一个功能完整的桌面客户端:创作者在里面创作,管理人员也在里面看任务,其他系统需要自动生产时再想办法复用客户端能力。把用户、运行时间和失败场景列出来以后,我发现“统一”只是界面上的统一,背后的三类问题完全不同。

最后拆成三个产品面,判据是三个真实差异:用户角色不同、运行生命周期不同、数据权威不同。

创作端解决有人参与的生产。创作者要编辑脚本、查看分镜、比较图片、试听音频、预览视频,还会要求只改其中一个节点。这类流程允许人参与,也允许在关键步骤停下来。本地受监督的完整生成是当前设计目标,但用户的 Mac 不能承担“关机以后任务仍然必须完成”的承诺。所以本地 Run 和服务端 Job 必须是两种明确状态:用户确认提交以后,服务端返回稳定 Job ID,此后客户端退出不应影响任务执行。

自动化后台解决运营和处置。这类用户不创作内容,他们关心今天多少任务失败、哪些内容等待审核、Webhook 是否送达、某个批次要不要重试。把这些塞进桌面客户端,管理页面就得依赖客户端安装,权限很难按运营角色划分,自动化任务出问题还得找到一台特定的 Mac。所以它更适合做成独立 Web 产品,直接访问 Platform API。这也让权限更容易说清楚:创作者改自己的输入和候选 Artifact,运营处理审核与失败任务,自动化账号只能提交和查询被授权的 Job。

服务端承担持久生产承诺。上游系统通过定时任务调用 API,平台自动生成视频,成功或失败后回调结果。这种流程不能启动桌面客户端,也不能依赖 OpenCode Session。服务端要自己拥有 Job、Step、Attempt 和幂等键,Worker 的 claim、lease、heartbeat 和故障恢复,共享对象存储和长期凭据,质量门、有限重试和人工审核入口,以及 Webhook 重试、去重和对账。API 接受任务以后,即使 API、Worker 和客户端依次重启,任务状态仍然要能从数据库恢复。Agent 回复、内存线程和本地 SQLite 都不能成为生产真相。

拆分时最容易动摇的地方是 Skill 的边界:既然 Agent 已经能调用图片、音频、渲染工具在本地跑出一个视频,能不能顺手把定时生产、失败重试、回调交付也放进 Skill?这个提议听起来很合理,少一套服务端,部署也简单。但往故障场景里推下去会发现,这里混在一起的是两种能力:一种是“把一次创作做出来”,另一种是“无论中间发生什么,都要把这次生产可靠地交付出去”。判断一段流程放在本地还是服务端,不能只看它能不能执行,要问它对外承诺了什么——客户端关机后谁继续执行、同一个请求重复提交怎样避免生产两次、执行进程被杀后谁接管、旧的执行实例回来后怎样禁止它覆盖新结果。这些不是提示词问题,是持久任务问题。所以 Skill 的边界就定成三件事:理解用户想完成什么;选择被批准的 typed tool,处理确认点和失败分支;根据 Manifest 和真实资源 ID 判断是否完成。队列、租约、心跳、长期重试、跨设备恢复和回调交付,留给服务端。

“离线”这个词也要拆开说。有人理解为不依赖我们自己的服务端,有人理解为模型必须在本机,还有人理解为拔掉网线照样完成全部工作。三个定义混在一起,后面的成本、隐私和可用性判断会全部错掉。至少要分三态:Platform-offline,业务平台的 API、队列和 Worker 全部停止,客户端仍能依靠本地 Skill、Tool 和工作目录完成一次有人监督的创作,但模型、图片、语音服务仍可能走公网;Provider-online,执行发生在本机,但生成能力来自外部 Provider;Fully-offline,模型、字体、编解码器、素材和许可证全部在本机。完全离线并不天然更好,它会带来模型体积、硬件要求和许可管理问题,不能因为产品上写了“本地”两个字就默认拥有。

测试时也要把请求分开计数:platform_request_count=0 只能证明没有依赖业务平台,推不出网络请求为零。我后来让每次测试 Run 都输出一份残余依赖清单,记录 dependency_type、能力版本、是否必需、失败表现和替代方案,再用三组失败测试验证:关闭业务平台时流程应该继续;阻断 Provider 网络时应该在对应生成阶段明确失败;拿掉本地编解码器时应该在媒体副作用之前被 preflight 拦住。以后用户反馈“上周能跑、今天不能跑”,先比较依赖版本和请求记录,不必重新猜一次环境。

拆开以后还要防止三边各自实现一套流程:创作端生成一种视频,自动化走另一套 Pipeline,管理端根据日志猜状态。所以要共享的是领域合同和生产内核——Project、Job、Step、Attempt,Artifact、Approval、Delivery,Schema、Event、Evaluation。能共享的 planning、imagery、audio、render 算法提取成共享核心,本地和服务端分别提供 Adapter。第一阶段也不需要因此拆很多微服务,模块化单体加独立 Worker 已经够把 API 请求和长耗时执行分开,进程边界先正确,比服务数量多更重要。

当然,三个产品面会增加开发、鉴权、部署和一致性成本。对于一个只有少量内部用户、没有自动化调用的早期产品,一个客户端加一个简单服务可能已经够用。拆分不应该来自“架构看起来更专业”,而应该来自前面说的三个真实差异:用户角色、运行生命周期、数据权威。后两项如果不存在,就没有必要提前做完整后台和持久 Worker。

Promote:本地到服务端的交接边界

三个产品面之间,最实际的连接问题是:本地到底应该提交什么给服务端?

最省事的方案是把整个 Workspace 上传,文件、配置、日志和聊天记录都在里面,服务端拿到想怎么处理都行。继续考虑隐私、幂等和失败恢复,我发现这个方案几乎把所有边界都打穿了:服务端开始依赖客户端目录布局,用户的原始材料和机器路径可能被无意上传,而且服务端很难判断哪些内容真的经过用户确认。

所以本地到服务端应该是一次明确的 Promote:冻结一份最小快照,包括本地 Run ID 和这次提交的幂等键、已确认输入的 Artifact ID/version/hash、必要产物的结构化引用、使用过的 Skill/Tool/模型/脚本版本、用户确认结果以及仍需服务端执行的目标。本地绝对路径、完整聊天 Session、未确认草稿、长期凭据和任意日志都不进快照。这一步看起来是在减少信息,实际上是在确定责任:服务端只能基于用户明确提交的内容继续运行。

幂等要围绕快照设计,规则两条:同一个 key 加同一个快照,重复提交应返回同一个服务端任务;同一个 key 对应不同快照,明确冲突,不能静默覆盖。这里的“同一个快照”不能靠文件名判断,要靠版本和内容 hash——用户在本地重新生成了一张图片,即使路径没变,它也是新的输入版本。服务端返回结果后,客户端还要把本地 Artifact 与服务端 Project、Job、Asset 显式关联起来,否则后续看到一个服务端结果,很难追溯它来自哪次本地确认。

还有一对动作不能合并:用户提交 planning 或中间产物、希望服务端继续 image、audio、render,这是 Promote inputs,结果是一个 Durable Job;用户在本地已经完成视频、只想把成片登记到平台,这是 publish approved final,结果是 Content/Asset,不应该再启动一次 Pipeline。两个动作的资源结果、计费语义、幂等范围和失败处理都不同,我不准备设计一个万能 submit 让服务端根据字段猜。模糊在这里不是灵活,是风险。

交接之后还有一个更长期的问题:planning、image、audio、render 这些能力本地和服务端都要跑,半年以后两边还是不是同一项能力?防分叉的办法是先分类,再决定共享什么。沿真实代码入口把每项能力的输入、输出、副作用、秘密、系统依赖和恢复方式列出来,分成四类:

  • Portable Core:不依赖请求上下文、数据库、对象存储和长期秘密的确定性逻辑,比如 parser、normalizer、时间计算、validator。这类只保留一份实现,本地 Tool 和服务端 Worker 共同调用。
  • Local Typed Tool:需要本机文件、媒体库或受控 Provider,但限制在 Workspace 内执行,比如图片处理和 FFmpeg 合成。
  • Durable Server Capability:需要队列、租约、共享资产、跨设备恢复或可靠交付的,留在服务端。
  • External Integration:图片、TTS、模型等外部服务,通过版本化 Adapter 接入。模型调用不能假装成本地纯能力,它仍然有授权、网络、成本和返回格式合同。

这个分类也正好回答了 Promote 的边界画在哪:Portable Core 的规则两边共享;Local Tool 的产物经用户确认后进入快照;Durable 能力只出现在快照里“仍需服务端执行的目标”中。

暂时共享不了实现的情况也存在。老代码和服务端上下文耦合得深,为了共享做大重构有时比维护两个版本更贵,所以我不把“立即共享代码”设成前置条件。暂时无法共享时,至少共享四样东西:Schema、fixture、validator 和 evaluation——同一版本 fixture 分别送进本地和服务端,产物进入同一组检查。重点是“暂时”两个字:任何复制实现都必须登记 Owner 和删除条件,否则“暂时”会变成默认架构。

漂移门禁也不能只测正常结果。正常 fixture 通过,只能说明 happy path 看起来相似,真正容易漂移的是失败和边界:必需文本缺失时两边是否都拒绝、Provider 返回不同音频格式时是否都按真实字节识别、输入版本变化后是否只失效真实下游、重复请求是否会多生成一个 Attempt、不支持的能力是明确报错还是偷偷降级。这些负例要和正常样例一起版本化,否则本地看似能跑,遇到异常走的是另一套产品逻辑。

parity 也不能定成两个 MP4 的 hash 一样——生成式能力同样的输入也未必得到字节相同的结果,那样测试基本没有意义。要检查的是四层一致性:输入输出 Schema 一致;lineage 精确引用输入版本,不把旧图片挂到新视频上;确定性合同一致,必需文本不丢、媒体属性满足同一规则;质量、成本和时延的差异在批准阈值内,超出就阻断或明确降级。另外不要只比较最终视频,成片碰巧都能播放,中间可能已经丢了必需文本。parity 要沿 Artifact 链逐阶段检查,发现偏差时才能定位是 planning 合同、Provider Adapter 还是 render 规则出了问题。

这里的证据状态也要说清楚:目前固定 fixture 的工程纵切能验证产物登记、Manifest 校验和恢复链路,但真实 Provider 下的质量、成本对比和生产级 Promote endpoint 都还没有证明。现在只能确认合同和 parity 的方向,不能说本地与服务端已经达到生产同品质。

用四个实验判断这套 Harness 是否成立

这次重新理解以后,我不再用“模型够不够强、Skill 数量够不够多”来判断这个项目的设计是否正确。更值得盯的是模型所处的运行环境,能不能稳定地把用户目标转化成最终结果。具体可以拆成四个可以证伪的实验:

  • 更换不同模型后,是否仍然能稳定完成同一套工作流;
  • 客户端和服务端输入同一个 Brief,是否能生成等价的 Manifest 和执行轨迹;
  • 中途杀掉进程后,是否可以继续执行而不重复危险操作;
  • 根据真实失败记录修改一个 Skill 后,是否能在新案例上提升,并且不破坏原有结果。

前三个实验通过,说明这是一套可以工作的 Narrative Harness。

最后一个实验对应的是自我改进能力:让系统根据历史执行轨迹发现弱点,自动提出 Prompt、Skill 和工作流策略的修改。这个方向要非常谨慎——可以允许系统提出候选修改,但评估器、权限、安全边界和保留测试集不能交给它自己改。特别是叙事创作的质量很难完全量化,只优化时长、清晰度、字幕完整率这些简单指标,最后可能得到一个技术上全部达标、但叙事节奏和审美很差的视频。选题、叙事、情绪和风格的判断必须留在人这里。

下一步的动作很具体:先把 Promote 的 Manifest snapshot Schema 和两条幂等语义冻结下来,分开 Promote inputs 和 publish final 两个命令,然后拿第一个实验——换模型跑同一套工作流——在真实流程上过一遍,看这套 Harness 是不是真的成立。