如果你写过真正的 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) => Effect<CompletionResponse, 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[]): Effect<AgentResult, 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 的行为太难预测,没有执行时间数据,你根本不知道哪一步在拖慢整体流程。这个习惯让我们的线上排查效率提高了不少,建议你也试一试。