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 Hooks | Flue 2.0 的核心 API:在 agent 函数体内调用的 use 开头函数,每个给 agent 一项能力 |
| render / re-render | 借自 React 的说法:agent 函数被执行一遍叫一次 render;Flue 里每次模型调用前都 re-render |
| usePersistentState | 签名类似 React 的 useState,但每次写入落到会话存储、跨回合跨重启存活 |
| durable conversation | Flue 的会话模型:已接收的输入在崩溃、重启、重新部署后仍然存在,中断的工作恢复到确定状态 |
| 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 在其生命周期里的最优能力清单不是常量。排查阶段要便宜的模型和只读工具,修复阶段要强模型和写权限,收尾阶段只该剩一个提交入口。静态清单逼你取所有阶段的并集:所有工具永远挂着,所有技能永远占着上下文。并集清单有三笔代价:
- 上下文成本——没用的工具定义每回合都占 token;
- 错误面——模型在错误的阶段能看见(并调用)错误的工具;
- 失去物理——你只能在提示词里恳求「现在请不要用 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.0 | hook(函数调用) | 下一次 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 长什么样。这两个实验能把本文最关键的两个论断从「文档说」升级为「我测过」。
参考来源
工程实践
- Flue 2.0 官方发布文(代码示例与关键引文出处)
- Flue Agent Hooks 官方指南(re-render 语义、条件钩子、持久状态、at-least-once)
- withastro/flue GitHub 仓库(README:包结构、部署目标、设计哲学)
- Latent Space:React for Agents 访谈(Schott 的动机自述、Pi、竞品格局)
arXiv 论文
- Voyager: An Open-Ended Embodied Agent with Large Language Models(arXiv:2305.16291)——skill 库概念的源头,谱系见站内专文
- Cognitive Architectures for Language Agents(arXiv:2309.02427)——把 agent 拆成记忆模块 + 结构化行动空间 + 决策过程的分析框架;用它看 Flue,hooks 声明的正是行动空间,usePersistentState 是外置的工作记忆
站内回链