Effect与Fold:用状态折叠模型构建多提供商Agent工程架构

发布时间:2026/10/22 10:14:46
Effect与Fold:用状态折叠模型构建多提供商Agent工程架构 如果你也是那种见到新框架先翻源码、再决定要不要用的开发者看到 Fold 这个名字时大概会愣一下它到底是个文件目录工具还是一次函数式编程的思想实验我第一次接触它时脑子里快速过了一遍 Effect 生态的已知模块完全没有印象。但等我真的花了一个周末把它跑起来之后我觉得它在 Agent 工程化这条路上给出了一个很值得聊的解法用 fold 思想把会话循环显式建模用 Effect 的类型系统和执行模型把多提供商差异、错误处理、并发调度全部收进框架内部。这篇文章写给三类人正在用 TypeScript 写 Agent、却被各家模型 SDK 割裂得头疼的开发者懂一点 Effect 但不知道它怎么落到 AI 应用上的函数式爱好者以及纯粹在选型阶段想找一个“结构化并发 多模型路由”参考案例的技术负责人。我会把 Fold 的设计动机、核心循环、多提供商适配层的实现思路以及我实际踩过的坑全部摊开讲不依赖文档复述尽量都是可以直接抄走的东西。1. 项目全貌Fold 到底是什么它替你解决的是哪一类麻烦1.1 Agent 工程的最大痛点SDK 割裂和编排黑盒做过 Agent 项目的朋友应该都有体会第一阶段写 demo 很快调一个模型、写一个 system prompt、循环调几次工具就出效果了。但一旦进入工程化阶段问题会像雨后春笋一样冒出来。第一个痛点是 SDK 割裂。不同的模型提供商有各自的消息结构、工具调用格式、流式协议和错误码。你为了支持两个厂商往往要在业务代码里写一堆if (provider xx)分支把响应体转来转去。更麻烦的是有些厂商支持工具调用有些只支持 JSON 输出有些对上下文窗口有严格限制这些能力差异如果不在类型层面暴露出来只能在运行时报错时才发现。第二个痛点是编排逻辑黑盒化。市面上很多 Agent 框架会帮你把“思考-行动-观察”循环藏在一个run()方法里。用起来确实省事但一旦你需要插入自定义日志、控制并行工具调用数量、针对不同模型调整终止条件就会被框架的抽象卡住。要么翻源码改内部实现要么在回调里写一堆 hack怎么都别扭。Fold 的定位恰好戳在这两个痛点上。它把整个 Agent 循环建模成一个显式的状态折叠过程每一步的输入是当前会话状态输出是新的会话状态如此反复直到满足终止条件。这个设计让你能看见每一轮发生了什么而不是被一个黑盒函数吞掉。同时多提供商的能力被抽象成统一接口所有厂商差异都收敛在适配层内部。1.2 “折叠”的语义与 Agent 循环的同构关系如果你写过 Haskell 或者 Scala对fold应该不陌生。foldLeft做的事情是从一个初始值开始遍历集合把每个元素依次带入累积函数最终得到一个结果。这个抽象看起来很简单但它把“遍历一个数据源”和“维护一个累积状态”两件事彻底解耦了。Agent 的主循环其实也是同样的形状。你有一个会话状态消息列表、上下文、步数每一次与模型交互都会产生新的消息和工具调用结果这些内容又被折叠回状态里成为下一次交互的输入。换句话说一个多轮 Agent 本质上就是一个以“消息列表”为累积值的折叠过程。Fold 把这个同构关系变成了框架的核心抽象。它没有把循环藏在某个隐式引擎里而是提供了一个可组合的状态转移函数。你定义初始状态、定义每一步如何从模型响应更新状态、定义何时终止剩下的循环控制和错误传播全部交给框架。这样业务代码里就只剩下“状态怎么变”的规则而不是“循环怎么跑”的机械步骤。1.3 为什么选择 Effect 底座可组合、高安全边界、结构化并发可能有人会问这个折叠逻辑用async/await加几个纯函数也能写为什么非要绑定 Effect这是一个非常关键的问题我花了挺长时间才真正理解背后的取舍。先说可组合性。async/await虽然易读但它的组合能力很弱。你没法轻松地对一个异步任务做超时、重试、并发控制、依赖注入因为这些行为都是隐式的。Effect 把程序看成一份可执行的数据结构你可以用普通的 pipe 操作把超时、重试、并发这些策略一层层叠加上去代码可读性反而变得更好。其次是高安全边界。Effect 的错误通道是显式的。一个EffectA, E, R类型清楚地说出了三件事成功时返回什么、失败时携带什么错误、运行前需要哪些依赖。这在多提供商场景里价值巨大——不同 SDK 会抛出千奇百怪的异常如果你把它们全部统一成ProviderError类型编译期就能保证所有错误分支都被处理过而不是等线上运行到某个模型限流时才炸开。最后是结构化并发。Agent 的典型场景里经常需要并行调用多个工具或者同时向多个提供商发起请求做对比测试。手写Promise.all加AbortController的复杂度会随着场景增加迅速失控。Effect 里Fiber与Effect.all可以把并行任务看成可取消、可监督的子任务整体生命周期清晰得多。这一点在后面工具调用编排里会看到具体价值。2. 核心架构Agent 循环与 Effect 执行模型的结合点2.1 状态折叠用 Effect.iterate 重写会话循环Fold 的核心执行引擎围绕一个非常直接的模型展开把每一轮模型交互看作状态转移函数用Effect.iterate驱动它循环执行。先定义一个统一的会话状态类型为了便于理解我这里给一个简化版import { Effect } from effect; export interface AgentState { readonly messages: ReadonlyArrayChatMessage; readonly stepCount: number; readonly completed: boolean; readonly output?: string; }然后定义“一步”的状态转移逻辑它是一个从状态到新状态的 Effectconst transition (state: AgentState) Effect.gen(function* () { const provider yield* resolveProvider(state.messages); const response yield* provider.chat({ messages: state.messages }); if (response.toolCalls response.toolCalls.length 0) { const results yield* Effect.all( response.toolCalls.map((call) executeTool(call)), { concurrency: unbounded } ); return { ...state, messages: [ ...state.messages, assistantMessage(response), ...results.map(toolResultMessage) ], stepCount: state.stepCount 1 }; } return { ...state, messages: [...state.messages, assistantMessage(response)], completed: true, output: response.content, stepCount: state.stepCount 1 }; });有了这个转移函数完整的 Agent 循环只是把初始状态丢进Effect.iterateconst runAgent (initial: AgentState) Effect.iterate(initial, { while: (state) !state.completed state.stepCount MAX_STEPS, body: (state) transition(state) });这段代码的优势在于终止条件、单步逻辑、状态数据结构各自独立你可以非常方便地针对单个环节写测试。想限制最大步数就改while条件想给每一步加日志就在transition里插一个Effect.tap想并发跑多个 Agent 实例整个运行器本身就是一个普通 Effect直接交给Effect.all处理即可。2.2 结构化并发并行工具调用不再是手写 Promise.allAgent 跑起来之后最常见的瓶颈是工具调用。大模型一次性返回三四个工具调用请求如果串行执行响应时间会累加但用Promise.all并行执行又面临取消和错误隔离的问题某个工具挂了其他工具的结果要不要保留整体任务被用户中断时这几个后台请求能不能跟着一起取消Effect 的结构化并发把这些问题变成了默认行为。上面的代码里使用了Effect.all(..., { concurrency: unbounded })它会为每个工具调用启动一个独立的 Fiber。如果某一个 Fiber 失败默认策略会中断其他 Fiber 并整体失败如果外层 Agent 被取消所有子 Fiber 也会被级联取消。这一点的工程价值很大。我在实际项目里遇到过工具 A 拉取远程数据卡了 30 秒工具 B 已经正常返回的情况。用传统 Promise 方式处理这种局部超时要么给每个 Promise 包一层Promise.race要么等最慢的回来非常啰嗦。而在 Fold 里你可以给整个工具调用阶段加一个总超时或者在单个工具执行的 Effect 上套Effect.timeout策略之间自由组合。2.3 Layer 依赖注入把 Provider 切换变成组件替换多提供商框架最怕的就是“换一家模型厂商业务代码跟着改一遍”。Fold 使用 Effect 的Context.Tag和Layer把 Provider 设计成可注入依赖运行 Agent 之前再决定到底用哪家。基础的服务接口可以这样定义export interface ChatProviderService { readonly id: string; readonly chat: (request: ChatRequest) EffectChatResponse, ProviderError; } export const ChatProvider Context.TagChatProviderService();配套的注册表服务负责维护多个 Provider 与模型名的路由关系export interface ProviderRegistry { readonly providers: ReadonlyArrayChatProviderService; readonly resolve: (model: string) EffectChatProviderService, ProviderError; } export const ProviderRegistry Context.TagProviderRegistry();在组装业务代码时服务之间只通过Context通信谁提供服务、谁消费服务都在Layer层声明。这意味着测试时可以注入一个固定返回假响应的 Provider完全不需要 mock 网络请求。生产环境也可以根据需要在同一个 Agent 实例上切换不同的 Provider 组合业务代码一行都不用改。2.4 错误通道LLM 的不稳定被显式建模我用过的模型 API 就没有一次不犯错的。限流、超时、敏感内容过滤、JSON 解析失败、上下文超长……每个错误都需要不同的处理策略。Fold 把错误分成可重试与不可重试两类并且用 Effect 的错误通道显式建模。举一个非常典型的限流场景class ProviderError extends Data.TaggedError(ProviderError){ readonly providerId: string; readonly code: rate_limit | timeout | bad_request | invalid_response; readonly retryable: boolean; } {}有了这个类型重试策略可以写成声明式配置const retryPolicy Schedule.exponential(200 millis) .pipe( Schedule.whileInput((error: ProviderError) error.retryable), Schedule.compose(Schedule.recurs(3)) );然后调用模型的位置统一写成const callWithRetry (request: ChatRequest) provider.chat(request).pipe( Effect.retry(retryPolicy), Effect.timeout(30 seconds) );整个错误链路的状态一目了然哪一步可能失败、失败之后怎么处理、最多重试几次都在类型系统里体现出来。相比在每个 SDK 的 catch 里写重复逻辑这套设计的可维护性高了一个级别。3. 多提供商适配层如何吞掉 SDK 之间的差异3.1 消息与工具调用的归一化结构做过多提供商接入的人都知道表面上看各家都是“传消息、收回复”实际上字段差异多到让人崩溃。有的厂商用content字段有的用text工具调用的参数有的是 JSON 字符串有的是结构化对象助手消息里可能还夹带着一个问题工具调用结果应该以什么格式回传。Fold 的适配层以一套统一的归一化消息类型为基准所有厂商 SDK 都转换成这个类型再进入核心循环export type ChatMessage | { readonly _tag: System; readonly content: string } | { readonly _tag: User; readonly content: string } | { readonly _tag: Assistant; readonly content: string; readonly toolCalls?: ReadonlyArrayToolCall } | { readonly _tag: Tool; readonly id: string; readonly content: string }; export interface ToolCall { readonly id: string; readonly name: string; readonly args: Recordstring, unknown; }任何厂商响应进到框架里第一步就是做归一化转换转好之后后续的 Agent 循环完全不需要感知厂商差异。这一步是一次性成本但省掉的是后续无数个“为什么这个字段在 Provider-B 里是 null”的排查夜。3.2 能力矩阵和模型路由的落点不同模型能力差异巨大有些模型支持原生工具调用有些只能输出 JSON有些响应快但上下文短。Fold 用一个能力矩阵描述每个 Provider 能做什么、不能做什么export interface ProviderCapabilities { readonly supportsTools: boolean; readonly supportsStreaming: boolean; readonly jsonMode: none | json_object | json_schema; readonly maxOutputTokens: number; readonly defaultModel: string; }路由逻辑在运行时根据任务特性选择 Provider如果一个请求必须使用工具调用就优先路由到supportsTools: true的服务如果任务只是简单的摘要生成就路由到响应更快、成本更低的模型。这个路由本身也是一个 Effect因此可以访问注册表、可以记录日志、可以在某家服务不可用时降级到另一家。我实际用下来觉得能力矩阵的价值不只是运行时路由它更是一种文档。团队里新来的同学想知道“我能不能让模型调用我的内部工具”看一眼矩阵配置就明白了不用去翻各家 SDK 的 release notes。3.3 流式响应的差异收口用 Stream 统一行为流式输出是另一个很容易被低估的坑。各家 SDK 的流式事件格式、结束标志、错误处理方式都不一样。有的在流中间抛错有的在结束时才返回错误码。如果每个 Provider 自己处理流核心循环里出现半个 token 发送到一半的情况会非常难调试。Fold 的适配层把流式输出统一为 Effect 的Stream类型。无论底层是 SSE、WebSocket 还是普通分块响应对外暴露的都是同一个可组合的流对象。这样上层可以统一做按词输出、断流重连、超时中断等操作。下面是一个示意const streamFromProvider (req: ChatRequest) Stream.unfoldEffect(0, (offset) provider.streamChunk(req, offset).pipe( Effect.map((chunk) (chunk.done ? Option.none() : Option.some([chunk.data, chunk.nextOffset]))) ) );如果你想在流式输出中做关键词过滤、增量解析 JSON、或者把多个 Provider 的流做对比都可以在Stream层用现成的操作符完成不需要关心底层协议。3.4 重试、限流与降级的协议级实现多提供商场景里稳定性需求会催生一套独立的策略代码。Fold 在适配层内置了直方图式的限流状态管理每个 Provider 记录过去一分钟的请求量和错误分布超过阈值时自动把新请求切换到备用 Provider。这个降级策略同样以 Effect 的重试调度表达const withFailover (primary: ChatProviderService, backup: ChatProviderService) (req: ChatRequest) primary.chat(req).pipe( Effect.catchIf( (err) err.code rate_limit || err.code timeout, () backup.chat(req) ) );这样写的好处是降级逻辑是显式的不依赖什么全局开关。你可以为每个任务单独指定主备策略例如大数据量生成任务用能力强的模型小任务优先走低延迟模型。4. 实操演示写一个最小可运行的多提供商 Agent4.1 环境准备与项目初始化实操部分我们快速搭一个最小可运行的 Agent。假设你已经有一个 Node.js 项目安装依赖时只需要保证 TypeScript 版本和 Effect 版本匹配。npm install effect/schema effect npm install --save-dev typescript tsxtsconfig.json 里建议启用strict模式因为 Effect 的很多类型推断在 strict 模式下才完整这也是我踩过的一次坑——之前为了省事关掉了strict结果自定义错误类型的收窄效果大打折扣。4.2 注册两个模型提供商这里我用 Provider-A 和 Provider-B 代称两家不同风格的模型服务。Provider-A 支持工具调用和流式输出Provider-B 更偏极简协议、主要支持文本与 JSON 模式。先实现两个 Provider 的适配器每个适配器只做一件事把归一化请求转换成厂商 SDK 的格式再把厂商响应转换成归一化类型。注册表服务负责把它们放进依赖注入容器export const ProviderRegistryLive Layer.sync(ProviderRegistry, () ({ providers: [providerA, providerB], resolve: (model) { const provider model.startsWith(A-) ? providerA : providerB; return Effect.succeed(provider); } }));这里故意用模型名前缀做路由逻辑简单直白方便你替换成自己的路由策略。4.3 定义工具与主循环定义一个简单的天气查询工具工具执行返回一个字符串结果。主循环和前面 2.1 节里展示的transition一致只是把工具函数的定义补全const weatherTool (city: string): Effectstring Effect.succeed(天气数据: ${city}, 温度 24°C, 晴。);把所有部分组装起来通过Effect.provide注入依赖后运行const program runAgent(initialState).pipe( Effect.provide(ProviderRegistryLive), Effect.map((state) state.output) ); Effect.runPromise(program).then((output) console.log(output));4.4 切换模型验证最小 demo 跑通后你只需要把路由逻辑改成始终返回 Provider-B再运行一遍。如果业务代码保持原样说明适配层把差异覆盖住了框架达到设计目标。这也是我推荐大家在做完任何多 Provider 框架后必做的一步测试切换厂商不应该引起业务层任何代码改动。实际测试中可能出现的问题我在下一章集中讲。5. 真实工程中的踩坑与排查补丁5.1 并行工具调用的后台 Fiber 泄漏第一次把工具调用并发放开后我遇到一个诡异的现象Agent 已经正常返回结果但进程久久不退出。排查后发现是某个工具调用的 Effect 在一个外部系统上挂了长连接外层 Agent 结束后这个 Fiber 没有被真正中断。这类问题在 Effect 里通常不是“忘了 cancel”而是“Fiber 被 fork 出来之后没人监督”。正确的做法是给工具调用阶段加一个总超时并且确认你使用的是结构化并发原语而不是裸Fiber.fork。Effect.all和Effect.race会自动处理子 Fiber 生命周期而裸 fork 需要你明确 join 或 interrupt。另外一个隐蔽场景是流式响应没有完全消费底层连接一直开着。我们最后在 Provider 适配器里把所有流操作都限定在Effect.timeout内才彻底解决这个问题。5.2 提供商响应字段不一致引发的解析错误有一次把某个 Provider 接入后工具调用参数解析一直报错。跟踪发现这个厂商返回的arguments是 JSON 字符串而另一个厂商直接给结构化对象。适配层转换时没有统一处理导致核心循环收到不一致的数据类型系统根本没拦住。这个问题的根因是归一化逻辑不够早。修复方式是在适配器最外层强制做 schema 解析把所有厂商响应先转成标准格式再向上传递const normalizeResponse (raw: unknown) Schema.decodeUnknown(ProviderResponseSchema)(raw);强烈建议所有适配层的转换函数都显式声明输入输出的 schema而不是靠手写 if 判断。Effect 的Schema库在这里能帮你省下大量调试时间同时让所有转换逻辑具备运行时校验能力。5.3 流式超时与中断恢复流式输出还有一个特殊问题服务端可能半分钟不发数据但连接又没断开。传统 fetch 的默认超时对这种情况毫无办法因为第一次响应已经收到了整个请求还没结束。在 Fold 里给流增加空转检测非常直接只要在 Stream 上组合超时操作stream.pipe( Stream.timeoutFail({ duration: 10 seconds, onTimeout: () new ProviderError({ code: timeout, retryable: true, providerId: A }) }) );这样设置之后模型思考过程超过 10 秒没有输出内容就会触发重试策略而不是卡在那里假装成功。这个模式强烈建议所有接入流式的读者都加上。5.4 Layer 依赖顺序导致的初始化失败Effect 的 Layer 组装很有表达力但初次上手时很容易把Layer.provide的顺序搞反。我遇到过一个典型的错误ProviderRegistry 依赖 LoggerService但我先把依赖注入到 Agent 主体再把 LoggerService 注入到 ProviderRegistry互相交叉导致运行时出现“缺少服务”的报告。排查方式也比较直接把 Layer 关系图画出来确保每个服务依赖的库都先provide。Effect 的错误信息在最新版本里已经能指出缺哪个 Tag但如果你用的是旧版本这种问题排查起来会比较费神。一个小建议在项目里把所有Layer定义集中在同一个文件中用类型别名标注每个 Layer 提供的服务减少组装时的认知负担。这个习惯在后面积累几十个服务时尤其有用。6. 个人体会这套设计真正值钱的地方在把 Fold 的思路消化完并且落地到一个内部工具项目之后我最大的感受是它没有做什么惊天动地的创新但它把 Agent 工程里那些经常被糊弄过去的细节全部提升成了头等概念。最值钱的部分是状态折叠模型。以前写 Agent循环是一个隐藏在run()里的黑盒你想在某个节点插日志、改策略只能靠 hack。现在整个循环就是一个 fold初始状态、单步转移、终止条件全都分开代码逐段可测。这件事对单机 demo 可能不重要但对需要长期演进的系统来说几乎是决定性的差异。多提供商适配层的归一化设计也值得反复琢磨。很多人以为支持多模型就是把各家 SDK 都装进来然后在业务里写 if else。Fold 的做法是用一套内部协议统一外部世界所有接入成本一次性收口到适配层长期收益非常明显。每接入一个新厂商业务层代码零改动这对团队迭代速度的帮助很大。如果你想把这个方案迁移到自己的项目里我的建议是不要急着照搬全部抽象。先从最小状态模型开始把 Agent 循环改成 fold 形状再把 Provider 接口统一最后逐步引入能力矩阵、路由、流式归一等高级特性。先让代码向你证明它值得再投入资源打磨。最后分享一个小经验在依赖注入和错误建模上多花一点时间是性价比最高的投入。很多 Agent 框架用起来舒服并不是因为算法多先进而是因为错误信息清楚、依赖关系清楚、状态变化清楚——这三样清楚了再复杂的系统也能稳住。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询