Mova 是我自造的一个 AI 辅助 2D 游戏角色动画工具。用户在聊天面板里用自然语言描述想要的动画——“做一个 16-bit 像素风的蓝色史莱姆,idle 四帧”——应用通过 LLM 解析意图、调用图像生成 API 出角色帧、走品红背景去除和切帧、登记到项目,最后导出 Sprite Sheet。桌面壳是 Tauri v2,前端 React 19 + Konva,后端 Rust,代码在 git.ruochongliang.top/lrc/Mova

这篇不讲用法,复盘三件事:一是 AgentOrchestrator 这套”浏览器侧 provider + skill + tool runner”的多轮工具调用模型是怎么设计的,为什么没把它做成更时髦的多 Agent / Plan-Execute 架构;二是 Tauri 在 Linux 上绑死 WebKitGTK 这个根本性的平台代价——它不是某个 bug,是这个框架选型带进来的结构性劣势;三是坦白记录这个项目欠下的性能债——这部分我没有粉饰,因为承认问题是修它的前提。目标读者是想做 AI 辅助创作工具的同行,以及正在评估”Tauri + React + Konva”这套组合能不能扛住桌面级交互的人。

技术栈与三层职责切分

先说技术选型,因为它直接决定了后文所有的取舍边界。

技术职责边界
桌面壳Tauri v2(Rust 2021)窗口、文件 I/O、图像处理、Git 集成、导出编码
前端React 19 + TypeScript 6 + Vite + Tailwind 4UI、画布编排、状态管理、AI agent 编排
画布Konva / react-konva像素图绘制、精灵预览、时间轴帧渲染
状态Zustand 57 个 store(project / animation / chat / settings / drawing / git / file-tree)

职责切分的核心判断是:AI 编排完全放在浏览器侧,Rust 只做”重活和脏活”。 Rust 后端注册了 26 个 IPC command + 2 个 plugin(dialog、fs),但它们全是无状态的工具:项目读写、图片 resize / split / 去背景、sprite sheet 合成、GIF 编码、Godot 帧导出、文件树扫描、Git 操作。没有任何 AI 逻辑下沉到 Rust——LLM 调用、工具调用循环、skill 路由都在 TypeScript 里。

这个划分不是技术洁癖,是务实判断。Rust 侧做 AI 的好处是能用更重的依赖、避开 WebView 的内存压力,但坏处是调试反馈循环慢一个数量级:改一行 prompt 要重编 Rust、重启应用,而前端改完 Vite HMR 秒级生效。对一个 prompt 和 skill 还在快速迭代的原型阶段,把 agent 内核放在能热重载的一侧更划算。Rust 留给真正吃 CPU 的图像处理和导出编码。

┌─────────────────────────────────────────────────────┐
│  WebView (React 19)                                  │
│  ┌───────────┐  ┌───────────┐  ┌──────────────────┐ │
│  │  Canvas   │  │  FileTree │  │  ChatPanel       │ │
│  │  (Konva)  │  │  GitPanel │  │   └ AgentOrch.   │ │
│  └─────┬─────┘  └─────┬─────┘  └────────┬─────────┘ │
│        │              │                 │            │
│        └──────────────┴─────────────────┘            │
│                       │ invoke()                     │
├───────────────────────┼─────────────────────────────┤
│  Rust (Tauri v2)      ▼                              │
│  26 commands: project / image / export / map /       │
│               file_tree / git / file_ops / watcher   │
│  deps: image 0.25 · gif 0.13 · gix 0.84 · notify 7  │
└─────────────────────────────────────────────────────┘

一个值得记下的 Rust 依赖取舍是 Git 集成选了 gix(gitoxide)而不是 git2(libgit2 绑定)Cargo.toml 里留了一段注释把决策理由钉死:gix 是纯 Rust,没有 C / libgit2 依赖,跨编译简单,二进制体积更小(约 2-3MB 对 5MB),对 MVP 需要的 init / status / add / commit / log 接口覆盖足够;代价是 gix 没 git2 那么稳定打磨,但为这个用例不值得背 libgit2 的 cmake 构建复杂度。这是一类反复出现的取舍——纯 Rust 依赖优先于更成熟但带 C 依赖的方案,除非成熟度差距大到影响功能。

AgentOrchestrator:单 Agent + Skill + Tool 的多轮循环

AI 内核是整个项目最有意思的部分,也是我最想讲清楚的设计。它的架构很轻,叫 AgentOrchestrator,本质是”单 Agent + Skill + Tool”的编排器,跑在浏览器侧。

// src/services/ai/agent-orchestrator.ts(精简)
export class AgentOrchestrator {
  private provider: LLMProvider | null = null
  private activeSkill: Skill | null = null
  private toolRunner: ToolRunner
  private conversationHistory: LLMMessage[] = []
  private maxToolRounds = 5
 
  async processUserMessage(userMessage, callbacks) {
    const messages = [
      { role: "system", content: this.activeSkill.systemPrompt
          + "\n\n" + this.activeSkill.buildContext() },
      ...this.conversationHistory,
      { role: "user", content: userMessage },
    ]
    while (roundsRemaining > 0) {
      // ① 流式调 LLM,收集文本 + tool_call
      for await (const chunk of this.provider.streamChat(...)) { ... }
      // ② 没工具调用就结束
      if (currentToolCalls.length === 0) break
      // ③ 执行工具,结果塞回 messages 进入下一轮
      const results = await this.toolRunner.executeToolCalls(currentToolCalls)
      messages.push(...results.map(toToolMessage))
      roundsRemaining--
    }
  }
}

核心是一个 while 循环 + 最多 5 轮工具调用:每轮把 system prompt + skill 上下文 + 完整历史 + 用户消息拼成请求,流式读 LLM 输出,同时收集 tool_call chunk;如果这一轮有工具调用,就执行它们、把结果作为 role: "tool" 消息塞回 messages 数组,进入下一轮;如果某轮 LLM 没再要工具,循环 break,最终文本作为 assistant 消息回传。maxToolRounds = 5 是个硬上限——跑满 5 轮还没收敛就当错误处理,回滚到本轮开始前的历史快照。

Skill 是 prompt + 工具集 + 上下文构建器的组合,不是独立 Agent。 目前四个 skill:general(项目查询 / 建议)、character-creation(生成角色图并登记)、sprite-generation(精灵表 + 去背景 + 切帧 + 登记)、map-generation(底图 + 道具 + 碰撞 + 预览)。每个 skill 自带 system prompt——比如 sprite skill 的 prompt 里写死了”背景必须 100% 纯品红 ff00ff”、“角色资产禁止用 1xN 单行条带会导致位移漂移”、“每次 generate_sprite_sheet 后必须先 process_sprite 再 extract”这类生产纪律。

工具调用链覆盖了游戏美术的核心闭环:generate_image / create_character / generate_sprite_sheet / process_sprite / extract_animation_frames / generate_map_base / generate_props / extract_props / compose_preview / create_collision_data。这些工具最终通过 Tauri invoke 落地到 Rust——保存图片、去背景、切帧、合成地图预览。所以 agent 不是在”聊天”,是在”干活”,每一步都有文件系统层面的产出。

意图路由用关键词匹配:SkillRegistry.getSkillForIntent 把用户消息 toLowerCase 后做 substring 匹配。“角色 / character / 像素角色” 路由到 character-creation,“sprite / 精灵 / pixel art” 路由到 sprite-generation。这个方案便宜稳定,但有典型误判——agent-kernel-strategy 报告里点了一个真实例子:用户说”我不要地图,只想让角色拿着 map 道具”,会被关键词 “map” 错误路由到 map-generation。

为什么不做多 Agent / Plan-Execute

这是最常被追问的问题。research 报告里我自己列了当前内核和”先进 Agent”的差距:缺显式 Planning、工具调用串行、无长期记忆、意图识别太简单、无结构化 Workbench 步骤、无权限确认。这些我都知道,但主动选择不做,理由是分阶段的性价比。

升级方向代价为什么暂时不做
多 Agent / 子智能体编排复杂度陡增,调试和成本翻倍当前工具闭环(角色→精灵→地图)线性依赖,单 Agent 串行已覆盖
显式 Plan-Execute要新增 plan 数据结构、步骤状态机、失败恢复MVP 阶段 skill prompt 里已写死工作流,够用
并行工具调用需处理依赖图、结果汇聚、API 成本失控生成类工具天然有依赖(先出图才能切帧),并行收益小
长期记忆 / 项目知识索引要建资源索引、偏好存储、检索原型阶段对话短,全量历史塞进 prompt 还没爆

maxToolRounds = 5 这个上限就是上述判断的具象化——它承认”我不打算让 agent 跑无限长链”,把复杂任务挡在门外,换可预测性。research 报告里的建议路线是 P0 先把现有能力包装成可展示的 Workbench Step(Queued / Running / Completed / Error),P1 再做模板化 Plan-Execute,P2 做 sprite / 地图 QA,P3 才引入有限并行。先把单 Agent 的可观察性补齐,比堆架构更值钱。

Important

AI 辅助工具的 agent 内核,第一版应该追求”可观察、可中断、可回滚”,而不是”自主、长链、多智能体”。一个能在 5 轮内收敛、失败时干净回滚的简单循环,比一个能跑 50 步但中途不可见的大架构更可信。

设计理念:AI 生成与人工精修的协作姿态

Mova 不是”一键生成”工具,这是个刻意的产品判断。当前市面上的 AI 画图工具多数停在”输入 prompt → 出一张图 → 满意就用不满意重抽”,但游戏美术生产的现实是:生成只是起点,精修才是主体。 一张精灵表出来,边缘可能碰格、帧大小可能漂移、背景可能不是纯品红,这些都要人工介入修。

所以画布侧有一整套像素级编辑能力:笔刷、橡皮、填充、形状工具、撤销重做、时间轴帧管理。AI 生成的精灵帧落盘后,用户可以在画布里直接擦掉误生成的元素、对齐边缘、补帧。这是把”AI 生成 + 人工精修”焊在同一个工作流里,而不是切成两个工具。

这个姿态决定了 agent 的工具设计:process_sprite 返回的 QC 数据里有 edge_touch、帧尺寸、grade(pass / pass_with_warnings / fail)字段,agent 的 sprite skill prompt 里写明了”grade 为 fail 时必须重生成,不许 extract”。工具不只产出文件,还产出可被 agent 自检的结构化质量信号——这让 agent 有机会在交付前自查,而不是把烂结果甩给用户。当然,现状离真正的”反思型 agent”还远,QC 字段目前更多是给 prompt 当约束,agent 是否真会据此重试,依赖模型遵循指令的稳定性。

项目存储是目录式而非数据库:project.json + characters/ / sheets/ / frames/ / maps/ / props/ 子目录。这是为了 git-friendly——用户可以对整个项目目录做版本控制,Rust 侧用 gix 提供 init / status / add / commit / log。选目录而非 SQLite,是因为动画素材的本单位是文件(一张张 PNG),文件系统天然就是它的索引,强行加一层 DB 反而割裂了”用 Git 管版本”这件事。

平台债一票否决:Linux 上的 WebKitGTK

前面讲的所有取舍都在”我能控制的范围”内,现在要讲一个我控制不了、却在 Linux 上决定体验上限的根因——Tauri 的 webview 内核。

Tauri 的卖点是用系统自带 webview,不捆绑 Chromium,换来小体积。代价是三个平台三种内核,性能天差地别:

平台Tauri 用的内核渲染性能
WindowsWebView2(Chromium 内核)第一梯队
macOSWKWebView(Apple WebKit)第一梯队
LinuxWebKitGTKlibwebkit2gtk-4.1明显落后

WebKitGTK 是 GNOME 阵营维护的 WebKit 实现。问题在于它既不是 Chromium,也不是 Apple 亲自维护的 WKWebView——它是一个资源相对单薄的开源项目,长期在 GPU 加速、CSS 复合层、Canvas 2D 性能上落后于 Chromium 系。这不是某个版本的偶发 bug,而是这个项目多年来的结构性处境,在 Tauri 社区、HN 讨论、Reddit 的 r/tauri 上是被反复批评的点。Tauri 在 Linux 上的体验,本质上被 WebKitGTK 的天花板焊死了。

这对 Mova 是直接命中。Mova 的画布是 Konva 驱动的——大量 <Layer> 重绘、Konva.Shape 节点增删、视口变换、像素级笔刷渲染,全是 Canvas 2D 和 DOM 复合层的高频操作。这正是 WebKitGTK 最弱的场景。我在 Linux 上跑的时候,画布交互的帧率明显不如在 Windows 上用 WebView2 跑同一个 build——同样的代码,换个内核,体感差一档。

lib.rs 里那段 WEBKIT_DISABLE_DMABUF_RENDERER=1 只是在这个大背景下打的一个补丁:WebKitGTK 的 DMABuf renderer 在某些 Mesa / 驱动组合下会让进程直接 abort,所以默认关掉它换稳定。但这是用稳定性换性能——本来就慢,现在还要关掉最快的渲染路径之一。 配套的 tauri:dev:linux-fast / linux-compat / linux-x11 / linux-force-compositing 四种启动模式,本质都是在”别崩”和”别太卡”之间找平衡点。roadmap 把”Linux 是性能优化主战场”写成方针,背后的潜台词就是——因为内核本身就弱,只能在应用层拼命补。

Important

选 Tauri 之前要诚实回答一个问题:你的应用是不是渲染重负载? 如果是 Canvas / 动画 / 大量 DOM 操作,且 Linux 是主要目标平台,WebKitGTK 的性能天花板会持续咬你。Tauri 的体积优势在这个权衡下未必划算——Electron 虽然大,但它在所有平台上都是 Chromium,性能一致且可预测。Mova 选 Tauri 是因为我对 Rust 侧的图像处理依赖更在意(纯 Rust 的 gix / image),且原型阶段我一个人开发、Linux 单平台测试为主,能忍;但如果这是个面向大量 Linux 用户、画布交互密集的产品,我会重新评估。

诚实复盘:那些没还上的性能债

这一节是本文的重点,也是我写得最不轻松的部分。Mova 现在能 pnpm tauri dev 跑起来,lint / typecheck / test / build / tauri build 全绿,有 56 个测试文件,Linux 上能打包成 .deb / .rpm / AppImage,看起来像个”中高成熟度”的原型。但能跑和跑得流畅是两回事,以下是 research/mova-local-assessment.md 里我自己识别、且承认还没修的性能债。

债一:文件树全量递归扫描

Rust 的 list_project_files 默认递归深度 10、上限 50,read_entries 对每个目录同步递归读取 metadata。它跳过了隐藏文件和 symlink(这点安全做得到位),但没有忽略 .git / node_modules / dist / target 这类重目录。前端拿到整棵树后,搜索时再递归 filter 一遍。

后果:项目一旦大起来,或者目录里混进了 node_modules,文件树首次加载和每次刷新都卡。这是最容易制造前后对比演示效果的优化(忽略重目录 + 懒加载子目录 + UI 显示”过大已截断”),但还没做。我承认这债拖着主要是因为我自己测试用的都是小项目,没被它真正咬过。

债二:canvas-area.tsx 是个 1432 行的巨组件

src/components/canvas/canvas-area.tsx 现在 1432 行(research 报告写的是”超千行”,现在更胖了)。图片加载、棋盘格背景、视口变换(缩放 / 平移)、绘图工具、时间轴帧预览、快捷键、保存状态——全塞在一个组件里。onMouseMove 直接驱动绘图工具,多个 Stage / Layer 绑定同一套事件。

这不是审美问题,是实打实的重渲染风险。一个 1400 行的组件,内部任何 state 变化都可能触发大范围重渲染,而画布交互是最高频的(鼠标移动每秒几十次)。research 报告给的改造路径很克制——不必重写渲染,只拆出 useCanvasImageCache / useDrawingHistory / useCanvasViewport 三个 hook,把状态和逻辑从组件里抽出来,降低耦合。这个我也还没动,因为拆 hook 的风险点是事件闭包里大量的 ref,动一处可能引入难复现的笔触错乱。

债三:图片缓存无上限 LRU

画布侧有个模块级 imageCacheimageLoadCache

// src/components/canvas/canvas-area.tsx
const imageCache = new Map<string, HTMLImageElement>()
const imageLoadCache = new Map<string, Promise<HTMLImageElement>>()

只增不减,没有 LRU 淘汰,没有总像素上限。时间轴还会 preloadImages 预加载所有帧。长时间编辑一个有上百张帧的项目,内存会持续膨胀且不释放。模块级 Map 的危险在于它活在组件生命周期之外,切项目都不会清。 修法是给缓存加上限(按数量或总像素),并在项目切换时主动 clear()。又是知道怎么修但没修的一条。

债四:绘图历史存 Konva 节点数组

撤销栈 drawingUndoStackRef 深度 50,看似有限,但每一步存的是 Konva.Shape | Konva.Group 节点引用数组,不是轻量 diff 或 bitmap patch。复杂笔刷操作一步可能引用几十个节点,大图上 50 步历史的内存和重绘成本都不低。理想的存法是 bitmap patch(只存变化区域)或命令模式(存操作 + 逆操作),但当前实现图省事直接存了节点引用。这是个典型的”先让它工作,再让它工作得好”的债。

债五:聊天历史无 token 裁剪

AgentOrchestrator 每轮把整个 conversationHistory 拼进请求,没有 token 预算、没有摘要、没有滑窗。聊天用 localStorage 持久化(zustand persist),所以历史只会越来越长。

后果分两层:UI 层长对话会变慢(每条消息重渲染);agent 层长对话会悄悄超上下文窗口然后报错,而用户看到的就是一句 “Maximum context length exceeded”,完全不知道是历史太长。修法是在 orchestrator 里加”最近 N 轮 + 早期摘要”,或者至少加个 token 估算的硬截断。这条优先级我排在很高,因为它是用户最容易踩到的坑。

债六:工具调用串行执行

ToolRunner.executeToolCalls 是 for 循环逐个 await:

for (const tc of toolCalls) {
  const result = await this.executeToolCall(tc.name, parsedArgs)
  results.push({ id: tc.id, name: tc.name, arguments: parsedArgs, result })
}

MVP 阶段合理——Mova 的工具多数有依赖(先生成图才能切帧),并行没收益。但遇到模型一轮里抛出多个互不依赖的调用(比如同时生成三个候选 prompt、同时 QA 检查多个资源),串行就把本可并行的延迟加起来了。agent-kernel-strategy 报告里把”有限并行”列为 P3,建议优先并行纯文本 / 检查类任务,少并行图像生成(避免 API 成本失控)。这条债的成本最低、收益最直接,但同样没动。

Important

这些债的共同特征是:每一项单独看都不致命,项目能跑能演示;但叠在一起构成了”规模一上来就崩”的悬崖。 我把它们列出来不是自我表扬”我有多诚实”,而是想说明一个更普遍的判断——原型阶段的性能债不可怕,可怕的是不建立一份”已知债务清单”,导致每次扩张都在不知道悬崖在哪的情况下加功能。Mova 现在这份清单,就是它的偿还路线图的输入。

Rust IPC 层的取舍

Rust 侧的设计也值得记一笔。26 个 command 按”领域”分包:project / image / export / map_commands / file_tree / git / file_ops / file_watcher,外加 chat(对话持久化)。每个 command 是无状态的 #[command] async fn,参数和返回值都 Serialize,靠 serde 在 IPC 边界做 camelCase 转换。

一个明确的取舍是不把 command 做成有状态的”agent 后端”。 没有任何 Rust command 知道”当前在跑哪个 skill”或”对话进行到第几轮”——这些状态全在前端。Rust 只暴露原子能力(读文件、切帧、编码 GIF),前端负责编排。好处是 Rust 侧极简、可测(每个 command 都能用 block_on 直接跑单测,file_tree.rs 里就有一堆 symlink 安全性测试);坏处是复杂的 agent 逻辑无法享受 Rust 的性能和内存安全。这是个清晰的分层:Rust 做工具,TypeScript 做编排。

另一个值得提的细节是 Linux 兼容性的启动模式矩阵:tauri:dev:linux-fast / linux-compat / linux-x11 / linux-force-compositing 四种,对应 WebKitGTK 在不同显示服务器和合成器下的不同脾气。这是上一节说的”平台债”在工程层的具象——因为内核不可控,只能在启动参数上多打几个挡位。

可迁移的原则

写到最后,提炼几条我认为能从 Mova 这个项目里迁移到其他 AI 辅助桌面工具的判断。

第一,agent 内核放在能热重载的那一侧。 Rust 编译慢,prompt 迭代快,把 agent 编排放前端、重活下沉 Rust,是这个阶段的最优分工。等 skill 和 prompt 稳定后再把编排下沉,顺序不能反。

第二,工具要产出可被自检的结构化质量信号,而不只是文件。 process_sprite 返回 edge_touch / grade 让 agent 有机会自查,这是把”干活”和”验收”焊在同一层的尝试。纯产出文件的工具,agent 只能盲信结果。

第三,单 Agent + 硬轮次上限 > 无约束的多 Agent。 maxToolRounds = 5 把不可控性挡在门外。原型阶段的可信度比自主性重要得多。

第四,AI 生成与人工精修必须同工作流。 一键生成工具的天花板是”用户满意重抽率”,而创作工具的天花板是”用户能精确改到想要的样子”。把画布像素编辑和 AI 生成放一个应用里,是为了不逼迫用户在两个工具间反复横跳。

第五,目录式存储优于数据库,当资产本单位是文件时。 加 SQLite 看起来”专业”,但割裂了 Git 版本控制这条已经存在的能力。能用文件系统当索引就别多加一层。

第六,也是最重要的:建立已知债务清单,比假装没有债更值钱。 Mova 的性能债我没全还,但每一项都写进了 research 报告,标了影响和修法。原型阶段的工程能力,很多时候体现在”你有多清楚自己欠了什么”,而不是”你声称自己有多干净”。

第七,分清”我能控制的债”和”平台塞给我的债”。 前六条债都是我自己代码写出来的,理论上都能还;但 WebKitGTK 那条不是——它是 Tauri 这个框架在 Linux 上的结构性代价,我再怎么优化应用层也冲不破内核天花板。选框架时要诚实评估这条:框架给你便利的同时,也把它底下的内核债转嫁给了你,而用户最终只会把这个卡顿算在你头上。

Mova 现在版本号 0.1.1,是个能跑、有测试、能打包,但性能悬崖清楚可见的原型。它不是成品,我也不打算把它包装成成品。如果你也在做类似的 AI 辅助创作工具,希望这些取舍和债务记录能帮你少踩几个坑——或者至少,让你在踩坑时知道这坑别人也踩过。