首页 / 资讯中心 / 文章详情

用Effect搭建多提供商Agent框架:Fold核心设计与实践

用Effect搭建多提供商Agent框架:Fold核心设计与实践 ★ FEATURED ARTICLE
如果你写过真正的 Agent大概率经历过这种阶段——业务逻辑写起来很快但模型一换就崩工具调用稍微复杂一点整个流程就开始在回调地狱和竞态条件里挣扎报错倒是能拿到但想判断这次该重试还是该换策略全靠玄学。我们团队在做一个内部 Agent 中台时决定把底层全部用 Effect 重写最后沉淀出一个叫 Fold 的框架。这篇文章就是 Fold 的核心设计解读一个基于 Effect、原生支持多提供商接入和工具调用的 Agent 框架到底是怎么搭起来的每一步设计背后是什么考量。适合正在用 TypeScript 写 Agent、或者对 Effect 好奇但不知道能拿来干嘛的开发者。先交代一下背景。Fold 不是一个学术玩具它要解决的是我们真实面对的一类问题同一套 Agent 逻辑要跑在多家模型服务商上同一个工具链要稳定地处理超时、限流、参数幻觉同一个会话要跨请求保持状态。而用普通 Promise 链去做这件事我们试过了代码会在第三个版本开始失控。1. 为什么把 Agent 框架押注在 Effect 上先说一个反直觉的结论Agent 框架的核心难点从来不是调用大模型本身。模型调用只是网络请求真正麻烦的是围绕它的一整套控制流——重试、超时、并发、副作用隔离、失败恢复。这些东西如果散落在业务代码里项目就会变成一座长满 if-else 的废墟。1.1 手写 Promise 管道的三个痛点在 Fold 之前我们的旧框架是标准的手写 Promise 管道调模型、解析响应、拿结果去执行工具、把工具结果拼回去再调模型。这个循环看着简单但跑了几个月就陆续暴露问题。第一个痛点是重试逻辑侵入业务代码。模型接口不是稳定服务限流和超时是常态。我们在每个调用点都加了类似的指数退避结果就是几十个文件里复制粘贴同一段逻辑某个文件忘了更新参数线上就出现一堆诡异行为。第二个痛点是错误处理没有结构。用 Promise 的 catch 拿到的永远是 unknown我必须手写一堆判别函数去猜这到底是被限流了、超时了还是模型返回格式不合法。猜对了还好猜错了就把错误信息直接塞给用户体验非常差。第三个痛点是组合性。Agent 经常要并行做多件事同时问多个工具、同时尝试两个推理路径、一个模型调用失败时降级到另一个模型。手写 Promise.all 加上一堆条件分支很快就变成没人敢改的面条代码。1.2 Effect 带来的关键能力组合性、类型安全、可重试Effect 是 TypeScript 生态里的一个效应系统核心思想是把会失败的副作用建模成一个普通的、可组合的值。它的类型签名是这样的Effect成功值类型, 失败值类型, 依赖类型。这个签名看起来抽象但落到 Agent 场景里每一条都有对应价值。最直接的收益是重试和退避变成了声明式操作。我不需要在业务里写 for 循环去重试只需要给某个 Effect 挂一个 retry 策略像这样import { Effect, Schedule, Duration } from effect; const safeCompletion Effect.retry(createCompletion({ messages }), { schedule: Schedule.exponential(100 millis, 2), // 每次退避翻倍 max: 5, while: (error) error.name RateLimitError || error.name TimeoutError, });重试策略本身变成了一个值可以在测试里直接 mock也可以按请求动态调整。这是 Promise 管道很难做到的。第二个收益是失败路径被类型系统记住了。写完 Effect 之后这个调用可能以 RateLimitError 失败是写进类型里的不是靠注释。这样我在写下游编排时编译器会强迫我处理这些情况而不是等到线上炸了再补。第三个收益是并发原语。Effect 里有 Fiber可以像线程一样轻量地 fork 和 join 多个任务配合 timeout、race、parallel 这些操作符Agent 里常见的同时问三个工具谁先回来用谁就变得很干净。1.3 一个前提团队有没有必要上 Effect虽然我在这篇文章里对 Effect 赞美有加但必须说清楚它不是银弹。如果你的 Agent 只调一次模型、工具就一两个、对稳定性要求不高那 Promise 完全够用不要为了技术新鲜感引入整套效应系统学习和排查成本是实打实的。Fold 会押注 Effect是因为我们的场景同时踩中了三个条件多提供商接入让错误模型很复杂工具链数量多且互相组合Agent 循环是长生命周期的每一轮都可能失败、中断、续跑。在这些条件下Effect 的类型安全和控制流能力才真正值回票价。如果你的项目也符合这三条再往下看就有了参考意义。2. 多提供商抽象层把对话做成一等公民Fold 叫多提供商 Agent 框架多提供商是整个设计的原点。市面上很多框架对多模型的支持就是在接口后面塞一堆 if而 Fold 的做法是把一次模型对话请求做成一等公民用 Schema 定义契约用适配器完成映射。2.1 用 Schema 定义 Provider 契约而非手写接口大多数 TypeScript 项目定义接口用 interface但 interface 只在编译期生效。模型返回的 JSON 是不可信的它可能缺字段、多字段、字段类型不对。用一个例子说明你定义response.choices[0].message.content是 string模型可能返回 null也可能返回一个对象。interface 不会帮你拦截运行时该崩还是崩。Fold 用的是 effect/schema 的 Schema 来描述请求和响应。Schema 的好处是既提供了类型推导又提供了运行时校验。同一份定义既是 TS 类型又是校验器。请求长这样import { Schema } from effect/schema; const Message Schema.Struct({ role: Schema.Union(Schema.Literal(system), Schema.Literal(user), Schema.Literal(assistant), Schema.Literal(tool)), content: Schema.String, toolCallId: Schema.optional(Schema.String), }); const CompletionRequest Schema.Struct({ messages: Schema.Array(Message), temperature: Schema.optional(Schema.Number, { default: () 0.7 }), maxTokens: Schema.optional(Schema.Number), model: Schema.optional(Schema.String), }); const CompletionResponse Schema.Struct({ content: Schema.String, finishReason: Schema.optional(Schema.Union(Schema.Literal(stop), Schema.Literal(tool_call), Schema.Literal(length))), toolCalls: Schema.optional(Schema.Array(ToolCall)), });这个设计的核心价值在Schema.decodeUnknown模型返回的数据进来先解码成功才进业务层失败就给一个结构化错误。你问为什么需要这一步答案很直接——模型输出越自由框架的边界就应该越严格。把不可信的数据挡在门口后面的 Agent 循环才能假定数据格式是安全的。2.2 适配器模式统一请求、结果和异常有了一份统一的 Schema 契约之后剩下的工作就是把每家模型服务商的方言翻译成这套契约。Fold 里每个提供商适配器只要实现一个方法export interface ChatProvider { readonly complete: (request: CompletionRequest) EffectCompletionResponse, ProviderError; }注意这里返回的是 Effect不是 Promise。这意味着 Provider 自己的限流、鉴权、网络调用都是 Effect可以被框架统一调度。实际实现时适配器内部做的事情无非是把CompletionRequest映射成服务商要求的请求体调用对应的 HTTP 客户端再把响应映射回CompletionResponse。不同服务商的差异点主要集中在字段名、认证方式、流式协议上。举两个典型例子。某国内厂商的接口走的是 OpenAI 兼容格式那么适配器要处理的就只是字段映射和认证头。另一个开源推理服务走的是不同协议可能还需要处理请求体嵌套结构。这些差异全部被适配器消化掉。上层 Agent 永远只跟统一的CompletionRequest/CompletionResponse打交道换提供商就是换一个依赖注入的 Provider 实例。这就是为什么 Fold 要多提供商设计而不仅仅是写一个便于切换的接口。接口只能保证编译期一致不能保证运行时行为一致。通过 Effect 的依赖注入Requirements我们可以把 Provider 作为上下文注入到整个 Agent 调用链中测试时换 mock生产时换真实实例整个过程不需要改 Agent 逻辑一行代码。2.3 流式输出与取消是怎么在 Effect 里收敛的多提供商里最麻烦的部分是流式输出。不同服务商的流式协议千奇百怪有的是 SSE 按 token 推有的是先推 meta 再推内容有的在流中间插入工具调用标记。Fold 的策略是不管上游协议多乱统一成 Effect 的 Stream 类型。const streamCompletion (request: CompletionRequest) Effect.withTies(run)( // 伪代码示意类型 Stream.make(ProviderMessage) );统一成 Stream 之后框架就获得了两件事。第一流可以被取消——Agent 循环里决定不再需要这段输出了直接 interrupt 掉对应的 Fiber底层 HTTP 流也会关闭。第二流可以被转换组合——比如把 token 流改成按句读聚合或者在流里检测到工具调用标记时切换处理模式。这里想分享一个我们踩过的细节流式传输的取消和 HTTP 连接释放是两回事。单纯中断 Fiber 不一定能关掉底层的网络连接必须在适配器层用Effect.ensuring确保流结束后释放客户端连接。否则跑一个长时间运行的 Agent 服务连接数会悄悄涨上去。3. 核心 Agent 循环工具调用、规划与副作用隔离Provider 抽象解决的是怎么调用模型而 Agent 框架真正向内的一层是怎么组织模型和工具之间的循环。Fold 的核心是一个递归 Effect每一轮循环都是可中断、可重试、可观测的。3.1 把 Agent 循环建模成递归 Effect最常见的 Agent 实现方式是用 while 循环条件满足就继续迭代。Fold 没有用 while而是把它建模成递归 Effect。原因很简单while 循环里每一步都共享同一个错误上下文一旦某一步爆炸很难判断该重跑哪一部分而递归 Effect 让每一轮循环都拥有独立的错误边界和重试策略。核心逻辑可以简化成这样的结构const runAgentLoop (messages: Message[]): EffectAgentResult, AgentError Effect.gen(function* () { const response yield* callProvider(messages); if (response.finishReason tool_call response.toolCalls) { const results yield* executeToolCalls(response.toolCalls, Effect.forEachSuccess); const newMessages appendMessages(messages, response, results); return yield* runAgentLoop(newMessages); // 递归进入下一轮 } return { answer: response.content }; });每一轮递归都接受上一轮的消息列表返回一个不含副作用描述的结果。这样做最大的好处是你可以很容易地在任意一层注入超时、重试、日志。比如整个任务最多跑 10 轮就可以写成一个外层包装而不是在循环体内手写计数器再判断。执行工具调用用的是Effect.forEachSuccess它还有一个姊妹函数Effect.forEach允许并发执行。工具之间如果没有依赖关系完全可以并行同时查询三个数据库谁先回来谁先进入下一步省掉的时间非常可观。3.2 工具注册表与参数校验Fold 里每一个工具都是注册表中的一个值。工具定义包括四部分名称、描述、参数 Schema、处理函数。处理函数返回的是 Effect 而不是 Promise这是有意为之——工具本身可能涉及读文件、访问外部 API、修改状态这些副作用要显式地处于 Effect 管辖内才能被统一调度、超时、取消。const weatherTool Tool.make({ name: get_weather, description: 查询指定城市当前的天气情况, parameters: Schema.Struct({ city: Schema.String }), execute: (params) Effect.tryPromise(() queryWeatherService(params.city)), });参数校验是工具链里最容易翻车的一环。模型给出的参数经常缺字段、类型错乱、枚举值不在范围内。Fold 里工具执行前强制做Schema.decodeUnknown校验失败不会直接崩溃而是变成一个参数错误结果交还给模型让模型重新组织。这个机制一开始我们没做后来发现模型确实会持续输出带幻觉的参数比如把城市名写成北京市北京市不校验的话工具层根本不知道该拿它怎么办。工具结果在传回给模型之前还需要处理一步序列化压缩。一个查询工具可能返回几十 KB 的 JSON直接塞进 messages 里下一轮调用模型时会挤占上下文窗口。Fold 的做法是工具结果统一转成一个精简的文本摘要超长字段截断嵌套结构拍平。这一步对成本影响极大后面还会展开讲。3.3 失败重试与退避策略让幻觉成本可控Agent 循环里的失败分两层模型调用失败和工具调用失败。这两层要分开处理。模型调用失败通常值得重试因为限流、超时是暂时性的但工具调用失败要小心——如果工具是因为参数幻觉而失败盲目重试只会放大成本。Fold 对工具失败的处理策略是把错误变成消息返回给模型。工具执行返回一个结构化结果要么是{ ok: true, data }要么是{ ok: false, error: ... }后者被塞回上下文模型看到错误描述后会自己判断是改参数重试还是换一个工具。这个设计的代价是每轮工具失败都会消耗一次模型调用但它比工具一失败就中止整个 Agent 流程要实用得多因为模型绝大多数情况下确实能自我纠偏。模型调用层的重试则交给 Schedule。我们实际用的策略组合是铁定不可重试的错误如参数校验失败、上下文超长直接短路不进入重试可重试错误用指数退避基础间隔 100ms上限 5 次整个 Agent 任务外层再包一个总超时超时即终止并返回一个带部分完成状态的 AgentError方便上层决定是否续跑。再提醒一个容易忽略的细节重试策略里的while条件要和 Schedule 分开理解。while决定什么错误值得重试Schedule 决定重试的节奏。很多刚接触 Effect 的开发者把这两者混在一起结果超时也重试、校验失败也重试费了一堆 token 拿到同样的失败。4. 状态管理与记忆窗口Effect Ref 与持久化的取舍Agent 不是无状态函数调用它有自己的生命周期当前轮次、累计 token、临时记忆、跨会话偏好。Fold 对状态的处理原则是一句话循环内用内存态跨会话用外部存储。4.1 循环内状态用 Ref跨会话记忆交给外部存储Effect 自带 Ref一种可变引用非常适合存循环内的轻量状态比如当前已执行的轮次数、累计 token 消耗、当前临时变量。Ref 的好处是修改操作可以组合进 Effect 链并能在 Fiber 之间安全地协调。举个例子我们可以在每次模型调用后更新一个计数器const state yield* Ref.make({ turns: 0, tokens: 0 }); yield* Ref.update(state, (s) ({ turns: s.turns 1, tokens: s.tokens response.usage.totalTokens }));但不要试图把跨会话记忆也放进 Ref。原因很简单Ref 是进程内内存态进程重启、多实例部署、灰度切换都会丢数据。Agent 一旦要跨会话续聊记忆就必须落到 Redis、数据库或专门的向量存储里。Fold 的做法是在收敛阶段把整个会话的消息列表序列化成一份会话存档用户可以显式地恢复这个存档开一个全新的 Agent 循环。这个设计也方便我们做观测——每一轮循环的状态变更都有日志问题排查时可以直接回放。4.2 上下文窗口管理预算思维每个模型都有上下文上限而 Agent 循环天然会吞消息一轮工具调用就可能产生好几条消息三个工具跑一轮上下文飞速增长。Fold 里把窗口管理写成显式的预算过程先把系统提示、工具描述、当前用户指令这三块设为刚性预算剩下的空间分配给历史消息。这里用一个近乎伪代码的方式展示预算分配的过程刚性预算系统提示固定长度 工具描述列表所有工具的名称和参数摘要 最近的用户消息弹性预算历史对话。每次循环开始前计算总长度如果超了优先丢弃最旧的 assistant 消息再丢 older 的 tool 消息保持最近几轮完整兜底策略如果连最近一轮都放不下那放弃部分工具描述只保留最可能被调用的工具摘要。这个预算策略不完美但很实用。核心思想是先保工具再保历史最后保细节。因为工具描述如果缺失模型根本不知道能调用什么而历史被截断的最坏情况是模型失去一些上下文但至少不会崩。4.3 记忆格式对工具调用的影响记忆格式也是我们在实战中反复调整的点。早期的 Fold 直接把工具返回的 JSON 原封不动存进历史然后发现只要多轮工具调用上下文马上爆炸。后来我们改成压缩摘要工具结果只保留结论性的字段丢弃中间过程的冗余数据。比如数据库查询返回 100 行记录摘要只保留查询到 100 条记录前 5 条为...符合条件字段为...另外还有一个细节工具调用的中间结果如果不想让模型看到可以塞进系统层而不是用户层。这样模型聚焦在最终答案上不会被中间步骤干扰。但这个选择有取舍——如果 Agent 需要反思式地修正自己的工具调用那又需要把中间结果开放给模型。Fold 目前的默认是开放但提供了开关让上层可以关闭这个取舍我们会在实际场景里按需调。5. 实测中的典型坑和解决思路最后这部分是真实的踩坑记录。Effect 确实解决问题但它也引入了新的摩擦。这里写三个我们被绊过的地方希望能帮你少走弯路。5.1 类型体操过载别被 Effect 的类型牵着走Effect 的类型系统很强大但代价是泛型嵌套会变得很深。特别是当你写一个复杂的 Agent 循环既有错误类型分层又有依赖类型注入时IDE 的自动提示经常会开始卡顿报错信息能写满一屏。我们的经验是两个原则。第一面向接口编程不要让具体 Effect 类型泄漏到业务层。在 Fold 里Agent 对外暴露的是一个简化接口内部实现细节全部隐藏。这样即使内部改了依赖类型外部调用方也不会被波及。第二善用Effect.infer和类型别名。写 Effect 脚本时先用轻量别名把核心类型固定下来避免在每一个函数签名里重复写一大串泛型。如果发现某处类型推导异常复杂大概率是设计上把状态放错了位置该抽模块而不是继续堆类型。5.2 流式与工具调用的并发冲突流式输出和工具调用在真实场景里经常并发出现模型先流式吐了一段分析文字然后在流中插入一个工具调用标记。Fold 最初对这种情况的处理是先等流结束再解析工具调用但实测中流可能长达几十秒工具请求在那里干等整个 Agent 的响应速度被拖慢。后来改成了边流边解析流经过一个解析器一旦识别出工具调用的起始标记就立即分离出一个并发 Fiber 去准备工具执行等内容流结束后如果工具已就绪就直接执行。这个改动让工具调用的等待时间大幅缩短但也引入了一个新问题如果模型最后在流中放弃了工具调用这种情况真的会发生那已经准备好的工具上下文就浪费了。我们的方案是在 Fiber 里加一个短超时超时未确认就取消不造成太严重的浪费。5.3 超时、取消与重试三者的边界最后一个坑是关于三个概念的边界超时、取消、重试。看似简单但在 Effect 里它们各自的语义不同组合起来很容易出问题。举一个实际例子一个模型调用 Effect 设置了 30 秒超时超时触发时 Effect 会被中断但底层 HTTP 请求不一定已经取消。如果 HTTP 客户端没有跟着中断连接会一直占着资源。我们的修复方式是用Effect.ensuring包住整个调用在 finally 里强制关闭客户端连接。另一个边界问题是重试改写了超时语义。假设你对一个模型调用设置 20 秒超时同时挂了一个 5 次重试那么总耗时可能远远超过 20 秒。很多初学者以为设了超时就是 20 秒内返回但实际是每一次尝试 20 秒最多 5 次。如果你真的要整个任务最多 30 秒那这个超时应该包在重试的外层而不是内层。Fold 里这两种超时都有分别叫尝试超时和总超时语义非常明确配置的时候不会混。提示在写 Agent 框架时建议把一次尝试的超时和整个任务的总超时分开设计并且写进文档。否则半年后你自己回头看配置也分辨不出某个 timeout 到底管的是哪一层。还有一个我们特别在意的设计原则Agent 框架的所有副作用入口都收敛到 Effect 管辖区。工具注册、Provider 调用、会话持久化全部通过 Effect 暴露给上层。这样一来测试变的非常舒服——只需要替换掉依赖就能跑一套完整的 Agent 流程不需要启动任何外部服务。Fold 的自动化测试就是基于这个能力写的每次改动代码后模拟客户端的集成测试都能覆盖到核心循环的多数分支。如果你正在设计自己的 Agent 框架或者打算把一个用 Promise 写的旧 Agent 迁移到 Effect我的建议是先别追求一步到位。先把 Provider 层用 Schema 建模跑通一个最简单的 Agent 循环然后把重试和超时接上最后再把工具链和记忆窗口加进去。每加一层都跑一遍测试观察一遍日志。这套路径我们走了三个月踩坑无数但最终的框架确实比最初的 Promise 版本结实得多。最后分享一个我们内部的小规范每一个 Agent 工具的执行时间都要打进日志里。不是因为要优化性能而是因为 Agent 的行为太难预测没有执行时间数据你根本不知道哪一步在拖慢整体流程。这个习惯让我们的线上排查效率提高了不少建议你也试一试。
阅读完成 · 觉得有帮助?
咨询建站