Flue 2.0 深读:能力是状态的函数——Astro 团队把 React 的等式搬进了 Agent

Astro 创始人 Fred Schott 的开源 TypeScript agent 框架 Flue 发布 2.0,首个稳定版的地基是 React 风格的 Agent Hooks:agent 函数在每次模型调用前重跑一遍,hooks 声明本回合的能力清单。深读它对着的根本约束(能力集合随会话状态变化,静态配置表达不了)、与 React 的一处关键差异(资源钩子允许条件调用)、以及与 DeepSeek dsh 的谱系对照:重算 vs 撤销,是可组合性的两条路线。

Astro 创始人 Fred K. Schott 最初想做的是「agent 界的 Next.js」。做着做着他改了主意,原话是:“But then I realized: maybe no one has even built the React for agents.”——也许还没有人给 agent 造过 React。2.0 是 Flue 的首个稳定版本,整个 API 围绕这个判断重写:agent 是一个普通的 TypeScript 函数,函数体里的 hooks 声明它的能力,返回值就是它的指令,而这个函数在每次模型调用前都会重新跑一遍

先说结论。如果你只带走一句话,就带走这个:

React 的核心等式是 UI = f(state);Flue 2.0 把它改写成 capabilities = f(state)——一个 agent 该有哪些工具、哪个模型、哪些技能,是会话状态的函数,每回合重算。静态配置文件表达不了「能力随状态变化」,而在重算的世界里,“移除一个能力”不需要任何撤销动作:下一次渲染里不声明它,它就不存在。

名词速查

术语一句话解释
harness包裹模型的有状态程序:决定模型每步看见什么、输出能对世界做什么(专文
Agent HooksFlue 2.0 的核心 API:在 agent 函数体内调用的 use 开头函数,每个给 agent 一项能力
render / re-render借自 React 的说法:agent 函数被执行一遍叫一次 render;Flue 里每次模型调用前都 re-render
usePersistentState签名类似 React 的 useState,但每次写入落到会话存储、跨回合跨重启存活
durable conversationFlue 的会话模型:已接收的输入在崩溃、重启、重新部署后仍然存在,中断的工作恢复到确定状态
MCP模型上下文协议,agent 挂载外部工具的开放标准(状态搬家篇
skill按需加载的可复用知识包,谱系从 Voyager 的技能库走到今天(skill 供应链篇

一、事实层:Flue 是什么,2.0 发了什么

先把可查证的事实摆齐(来源见文末,均为本次检索核实过的官方材料与访谈,我没有亲手跑过 Flue——文末有验证实验):

  • Flue 是开源 TypeScript agent 框架,托管在 GitHub 的 withastro 组织下,由 Astro 创始人 Fred K. Schott 主导;它不依赖 Astro,是独立框架。定位原话是 harness 而非 SDK:会话、工具、技能、指令、文件系统、沙箱,全套运行时。
  • 2.0 是首个稳定版本(官方发布文),头条特性是 Agent Hooks,官方称有 16 个内置钩子;同批发布的还有新 CLI(flue run)、Vite 构建架构、无状态 MCP 支持,以及官方所说的 200+ 项修复。
  • 包结构(据官方 README):@flue/runtime 是核心,外围有 @flue/cli@flue/sdk(客户端)、@flue/vite@flue/postgres(持久化)、@flue/opentelemetry(追踪)。
  • 部署目标:Node、Cloudflare Workers、GitHub Actions、GitLab CI、Daytona 等。据官方材料,跑在 Cloudflare Workers 上时每个 agent id 对应一个 Durable Object,会话状态与运行时同地存放(未亲手验证)。
  • 据 Latent Space 访谈,Flue 构建于开源项目 Pi 之上;Schott 在访谈中的立场是 harness 不是 feature 而是 fundamental——大意为「没有 harness 的 agent 不算 agent」(访谈转述,非逐字引文)。

一句话背景补完:这已经是 2026 年第二个把「harness 本体」开源出来的 TypeScript 框架——上一个是八月中旬的 DeepSeek dsh。两者对着同一个问题给了截然不同的答案,第五节专门对照。

二、根本约束:静态清单为什么会崩

Flue 1.0 的 API 和几乎所有 agent 框架一样,是静态配置对象

// Flue 1.0 API(引自官方发布文)
export default defineAgent(() => ({
  model:   'moonshot/kimi-k2',
  tools:   [replyToIssue],
  skills:  [triage, verify],
  sandbox: local(),
  instructions,
}));

官方发布文对它的死因只有一句:“The static agent approach worked well for simple use-cases, but started to break down for more complex, non-trivial agents and multi-step workflows.”——简单场景够用,复杂多步工作流开始崩。

崩在哪里?发布文没有展开,我的拆法是:一个 agent 在其生命周期里的最优能力清单不是常量。排查阶段要便宜的模型和只读工具,修复阶段要强模型和写权限,收尾阶段只该剩一个提交入口。静态清单逼你取所有阶段的并集:所有工具永远挂着,所有技能永远占着上下文。并集清单有三笔代价:

  1. 上下文成本——没用的工具定义每回合都占 token;
  2. 错误面——模型在错误的阶段能看见(并调用)错误的工具;
  3. 失去物理——你只能在提示词里恳求「现在请不要用 X」,而《什么才是好的 Harness》的结论是:提示词里的规则是恳求,harness 里的规则才是物理法则。

已有框架对这个约束的主流回答是:LangGraph 式方案里每个节点绑定自己的配置,能力随「你在图的哪个节点」变化。Flue 2.0 的回答不引入图,而是换表示——同一个例子在 2.0 里长这样:

// Flue 2.0 API(引自官方发布文)
export function IssueTriageAgent() {
  useModel('moonshot/kimi-k2');
  useTool(replyToIssue);
  useSkill(triage);
  useSkill(verify);
  useSandbox(local());
  return instructions;
}

单看这段像是把配置对象换了个写法,没有新信息。关键在于它是函数,而函数体里可以有 if——下一节展开这件事为什么是全部差别所在。

发布文里点破灵感来源的那句值得逐字引:“The same problem that React Hooks was invented to solve applies to agents as well: How do you compose functionality at the lowest, most fundamental level?”——React Hooks 当年要解决的问题,agent 也有:如何在最底层组合功能。

三、核心等式:capabilities = f(state)

Flue 文档定义的运行时语义(官方文档原文):agent 函数 “re-renders on every model call and re-runs its hooks”——每次模型调用前重新执行一遍,hooks 重新声明一遍本回合的能力清单。runtime 拿新清单和上回合对比,把变化告知模型,维持对话连贯。

配上持久状态,反馈闭环就成立了:

flowchart TD
    M[消息到达] --> R["重跑 agent 函数<br>= 一次 render"]
    R --> C["hooks 声明本回合能力清单<br>model / tools / skills / sandbox"]
    C --> D["runtime 对比上回合清单<br>把变化告知模型"]
    D --> L[模型调用]
    L -->|工具调用| T["执行工具<br>可能 setState"]
    T --> S[("会话存储<br>唯一事实源")]
    S -.->|"下一回合 render 读到新状态"| R
    L -->|无工具调用| O[输出回复]

读这张图的要点:工具执行写状态,状态决定下一次 render 的能力清单——agent 通过自己的行动重塑自己的 harness。这是反馈闭环长在框架最底层的样子,而不是长在某个编排层里。

官方最小示例,五行看全所有要素:

// 引自官方发布文
export function Assistant() {
  const [count, setCount] = usePersistentState('count', 0);
  useAgentStart(() => setCount((n) => n + 1));
  useModel('moonshot/kimi-k2');
  return `You are a helpful assistant. This conversation has ${count} messages.`;
}

与 React 的一处关键差异:资源钩子允许条件调用

写过 React 的人第一反应是 Rules of Hooks:hooks 不能放进 if。Flue 文档明确反着来(原文):“Unlike React, resource hooks can be added and removed conditionally.”——工具、技能、子代理这类资源钩子可以条件增删。

为什么 Flue 敢破这条规矩?文档没有解释,以下是我的推断:React 的 useState 不带名字,靠调用顺序把第 N 次调用配对到第 N 个状态槽,所以顺序必须每次 render 都一致;而 Flue 的 usePersistentState('count', 0)显式字符串 key,状态按名字寻址,与调用顺序无关。资源钩子那边则根本不是状态槽——它们产出的是一份声明式清单,runtime 只需要对清单做 diff。寻址方式变了,顺序约束就没有存在的理由。

这个「允许条件」不是语法糖,它就是第二节三笔代价的解法。官方的动态升级示例:

// 引自官方发布文
export function CompanySlackAgent() {
  const [isEnhanced, setEnhanced] = usePersistentState("isEnhanced", false);
  useTool({ name: "enhance", description: "...", run: () => setEnhanced(true) });
  if (isEnhanced) {
    useModel('anthropic/fable-5-0');
    useSandbox(daytona());
    useTool(/* ... */);
    useSkill(/* ... */);
    useSubagent(/* ... */);
  } else {
    useModel('moonshot/kimi-k2');
  }
  return `You are a helpful assistant.`;
}

注意这里的门禁性质:isEnhanced 为假时,强模型和沙箱不是被提示词禁用的,是根本没被挂载。模型无法调用一个不存在于清单里的工具——这是物理,不是恳求。渐进解锁(progressive unlock)从一个需要编排层配合的高级模式,降级成一条 if 语句。

用「表示决定成败」这面透镜看

AI 系统史上反复出现的一课是:换个表示,难题变易题。「能力随状态动态变化」在静态配置的表示下是难题(要引入图、要写编排器),在「agent 是状态的函数」这个表示下是平凡的(写 if 就行)。Flue 2.0 整个版本做的事情,本质上只是这一次表示切换;16 个钩子都是这个表示下的自然推论。

四、两个例子,接住站内三条旧线索

线索一:状态机写进函数体——Loop 还是 Graph 的第三种答案

官方把开头的 triage agent 扩成完整工作流:

// 引自官方发布文
export function IssueTriageAgent({ id }) {
  useSandbox(local());
  const [step, setStep] = usePersistentState('step', 'reproduce');
  if (step === 'reproduce') {
    useModel('anthropic/sonnet-5-0');
    useSkill(reproChecklist);
    useTool({ name: 'submit_repro', description: '...', run: () => setStep('diagnose') });
  }
  if (step === 'diagnose') {
    useModel('anthropic/fable-5-0');
    useSkill(debuggingGuide);
    useTool({ name: 'submit_diagnosis', description: '...', run: () => setStep('report') });
  }
  if (step === 'report') {
    useModel('anthropic/sonnet-5-0');
    useTool(postGitHubComment);
  }
  return `Follow the workflow to triage GitHub issue ${id}: reproduce -> diagnose -> report.`;
}

《Loop 还是 Graph》拆过:两派争的是「下一步由谁决定」这根连续谱。这段代码是谱上一个很讲究的点位——图在代码里,但图的边由模型触发。三个阶段是普通 if,状态迁移只发生在 setStep 里,而 setStep 藏在工具的 run 里:模型判断「复现完成了」并调用 submit_repro,代码执行迁移。判断归模型,迁移归代码。模型没法跳过阶段(report 阶段的工具在 reproduce 阶段不存在),也没法伪造迁移(setStep 不暴露给模型,只暴露语义化的提交工具)。

每个阶段还各配了模型和技能——排查用 sonnet 档,诊断换 fable 档。能力清单随阶段切换这件事,在图框架里是每个节点一份配置,在 Flue 里是 if 块里的几行钩子。表达的信息量相同,表示的重量差一个编排层。

线索二:状态住在哪——会话存储是唯一事实源

usePersistentState 的语义(官方文档原文):“every write is recorded in the conversation’s storage, and every render reads the latest value back — across turns, across restarts, for the life of the conversation.”——每次写入落进会话存储,每次 render 读回最新值,跨回合、跨重启,直到会话结束。值必须 JSON 可序列化,更新推荐函数式((n) => n + 1)。

《MCP 状态搬家》那篇的结论在这里再次应验:状态不会消失,只会搬家;关键是状态住在哪、跟谁共命运。Flue 的选择是全部状态与会话共命运:运行时进程是可丢弃的,会话存储是唯一事实源。这也解释了 2.0 为什么同批推出「无状态 MCP 支持」——MCP 2026-07-28 改版把会话状态从服务器内存里赶了出来,Flue 正好是那次改版期待的客户端形态。

线索三:at-least-once 与双重发布问题

生命周期钩子(useAgentStart 等)的回调语义,官方文档写得很诚实:至少执行一次——已完成的工作不重复,被中断的工作重试;如果回调里的操作不幂等,官方建议用持久状态做守卫。

《Agent 的断点续传》开头讲过那个事故:发布接口调用成功了,确认没写进状态,重启后 agent 又发了一遍。Flue 把这个问题的责任边界划得很清楚:框架保证「恢复到确定状态 + 回调至少一次」,副作用的幂等性留给你。它没有解决那篇文章说的「恢复出来的世界是错的」问题——只是把问题从「你自己都不知道有这个坑」变成「文档明确告诉你坑在哪」。这是进步,但别把它读成免疫。

五、谱系对照:重算 vs 撤销

2026 年到目前为止,「可组合的 harness」有了两个开源的 TypeScript 答案。把它们和两个老前辈放进一张表(此表是我的对照分析,各家事实来自各自官方材料):

组合单元移除一个能力 = ?组合安全靠什么控制流真相在哪
Flue 2.0hook(函数调用)下一次 render 不声明它runtime 对清单做 diff代码(if + 持久状态),边由模型触发
DeepSeek dsh插件(effect/coeffect)执行该效应的逆操作Cordis 论文证明的合流性质插件化,连 agent loop 都可换
Claude Agent SDK配置 + 运行时机制不在此抽象层工程纪律固定 loop 归框架(拆解
LangGraph 式图框架图节点不进入该节点图的静态结构显式图归开发者

Flue 和 dsh 的对比最有嚼头,因为它们是同一个问题的两条经典路线,借图形界面编程的老词:immediate mode vs retained mode(这是我的类比,两家官方都没这么说)。dsh 是 retained mode——环境里保留着已安装的效应,移除靠执行逆操作,所以需要一篇 88 页的论文证明「任意顺序装卸不炸」;Flue 是 immediate mode——每回合从状态重算全量清单,没有「卸载」这个动作,自然不需要证明卸载安全。代价换了个地方:dsh 把复杂度付在数学上,Flue 把复杂度付在 reconciler 上(清单 diff、变化如何告知模型、模型对能力突变的适应),而后者的行为正确性目前只有文档描述,没有形式保证。

我的判断:这两条路线没有高下,选择取决于你换东西的频率和粒度。频繁换整块基础设施(模型适配器、沙箱后端、loop 本身)选 retained mode 的插件系统;能力随会话状态高频波动选 immediate mode 的重算。dsh 优化的是运维时的可替换性,Flue 优化的是运行时的可变性。

六、类比在哪里失效:给「React for agents」泼三瓢冷水

绿灯思维要求主动接住怀疑读者的问题。「React for agents」是个漂亮口号,但类比有三处明确的失效边界:

第一瓢:render 不纯。 React 的 render 是纯函数,跑一百遍没有代价、可以随时丢弃重来——这是整个 React 模型(并发渲染、时间切片)的地基。Flue 的一个「回合」里有真实世界的副作用:工具调过了、评论发出去了、文件删掉了。reconciler 能 diff 能力清单,没有任何 reconciler 能 diff 世界。所以 Flue 只能给 at-least-once 加持久状态守卫,而不能像 React 那样承诺「render 随便重跑」。类比迁移的是结构,不是纯度。

第二瓢:经济学不迁移。 React re-render 的成本是毫秒级 CPU,所以「全量重算」是个好交易。Flue 里 agent 函数本身的重跑也便宜,但它配置的是一次昂贵的模型调用——每回合清单变化会带来模型侧的适应成本(工具突然出现/消失,模型要重新理解可用动作空间)。文档说 runtime 会「向模型公布变化以保持对话连贯」,但公布本身占上下文,频繁的能力抖动对模型行为的影响,我没有找到官方评测数据(未验证,这是我最想看到 benchmark 的一点)。

第三瓢:动态清单是审计的敌人(我的观点)。 静态配置有个被低估的美德:安全审查时能力集合一目了然。capabilities = f(state) 意味着要枚举一个 agent 可能拥有的全部能力,你得遍历状态空间——上面那个 Slack agent 有没有可能在某条状态路径上同时拿到强模型和生产沙箱?答案藏在所有 if 的组合里。skill 供应链那篇的教训在这里复现:能力越动态,越需要锁文件式的静态可审计工件对冲。Flue 目前没有提供「导出所有可达清单」的工具(据我对文档的检索,未见提及——如有遗漏欢迎指正)。

七、带得走的东西

一张判断速查表:

你面对的问题这篇给你的判断
Flue 2.0 的本质创新是什么一次表示切换:capabilities = f(state),每回合重算
为什么静态 agent 配置会崩最优能力清单不是常量,静态配置逼你取并集,付上下文、错误面、失物理三笔代价
它和 React Hooks 真正的共同点在最底层组合功能:自定义钩子把「MCP 连接+鉴权」打包成一个可复用函数
它和 React 的关键差异资源钩子允许条件调用;我的推断:状态按显式 key 寻址,不依赖调用顺序
渐进解锁工具怎么做一条 if——未挂载的工具是物理不存在,不是提示词禁用
Flue vs DeepSeek dsh 怎么选重算 vs 撤销:能力随会话状态高频变化选 Flue 式,频繁换基础设施块选 dsh 式
最该警惕什么render 不纯(副作用幂等性归你)、能力抖动的模型侧成本无公开评测、动态清单难静态审计

诚实的提醒:本文所有 Flue 行为描述来自官方发布文、官方文档和 Latent Space 访谈(均为本次写作时检索核实),代码示例逐字引自官方发布文;我没有亲手运行过 Flue,「16 个内置钩子」「200+ 修复」等数字是官方口径,Cloudflare Durable Object 部署细节和「构建于 Pi 之上」来自二手材料未亲手验证。第三节关于「条件钩子为什么可行」的调用顺序分析、第五节的 immediate/retained mode 类比、第六节全部三瓢冷水,是我的推断和观点,不是官方说法。

成本最低的验证实验(读者和我都能跑,预计半小时内):照官方 Getting Started 初始化一个项目,把第四节的三阶段状态机抄进去,然后做两件事——(1) 在 reproduce 阶段让模型尝试调用 report 阶段的 postGitHubComment,验证「未挂载即物理不存在」;(2) 在 useAgentStart 回调里打日志后 kill 进程再重启,数一数回调实际执行了几次,亲眼看看 at-least-once 长什么样。这两个实验能把本文最关键的两个论断从「文档说」升级为「我测过」。

参考来源

工程实践

arXiv 论文

站内回链