最近在评估 TypeScript 下的 Agent 框架时,我注意到一个很有意思的方向:把 Effect 生态作为底座,去构建一个多提供商 Agent 核心框架。这类框架通常不急着给你封装好聊天机器人,而是先把模型调用、工具执行、记忆状态这些 Agent 核心链路,全部建立在一套类型安全的编排体系上。我重点看了其中一款以 Fold 命名的设计,跑通了一个最小示例之后,明显感觉到它和我以前用传统回调式 SDK 写 Agent 的体验很不一样。
Fold 解决的核心问题很明确:第一,避免被某一家模型服务商绑定,让同一个 Agent 可以在不同大模型之间切换;第二,把 Agent 过程中最容易乱的并发、重试、超时、取消这些副作用,纳入统一的类型系统管理。适合的人群也很清楚——正用 TypeScript 编写 Agent 服务、需要在多家大模型接口之间做路由和灾备、对代码可维护性有要求的开发者。如果你正准备选型 Agent 框架,或者已经在用 Effect 但还没找到合适的 Agent 层方案,这篇文章可以帮你少走不少弯路。
1. 内容整体设计与思路拆解
1.1 为什么 Agent 框架需要一个 Effect 层
写一个单轮问答的 Agent 非常简单,调用一次模型接口,把字符串返回给用户就结束了。但真实场景远没有这么轻松:一次对话可能要经历多轮模型往返,每轮还可能触发工具调用,工具执行结果又作为新上下文回到模型。更麻烦的是,你还要处理流式输出、用户中途取消、请求超时、某个服务商临时不可用、自动切换到备用模型……这些逻辑如果用传统的 Promise 链和 try/catch 去堆,代码会迅速膨胀成一团乱麻。
Effect 的核心思路,是把副作用变成"可以被组合的值":一次模型调用在类型层面被描述为Effect<Environment, Error, Success>,表示它依赖什么环境、可能抛出什么错误、成功之后返回什么。你不需要在写业务逻辑的那一刻就去执行它,而是可以先组合、重试、加超时、注入依赖,最后再交给运行时去跑。这个特性对 Agent 开发者来说几乎就是刚需,因为 Agent 的主循环本质上就是一连串需要精细控制的中断、重试和状态传递的副作用链。
我重点想说的是"原生"这两个字。很多框架也用了 Effect 或类似函数式工具,但只是拿来跑任务队列,组件内部仍然是普通 class。Fold 不一样,它的 Provider、路由策略、工具执行器、记忆存储,全是用 Effect 的 Service 体系声明出来的。你在读取代码时,能看到完整的依赖图谱和管理生命周期,而不是一个包着 Effect API 外皮的命令式写法。这种"全链路 Effect 化"带来的直接收益是:框架内部任何一环都能被替换或 mock,测试时不需要启动真实模型服务。
1.2 Provider 抽象与多提供商路由是怎么设计的
多提供商支持的第一步,是把各家接口的差异收敛到一个统一抽象上。这里至少要覆盖四个能力:文本补全、流式补全、工具调用、结构化输出。不同服务商的能力差别很大,有些支持工具调用,有些不支持;有些返回稳定的 JSON,有些只适合文本对话。Fold 的做法不是把这些差异全部抹平,而是给每个 Provider 声明能力标记,路由层根据标记来决定哪些模型能承担当前任务。我觉得这个设计很务实——你不应该要求所有模型都能做所有事情,但你的主流程应当有统一的处理方式。
适配器的职责分成两个方向。请求方向要把框架的统一消息结构转换成目标服务商的参数;响应方向要把各家返回的千奇百怪的数据格式解析成统一的ModelResponse。这一步非常考验细节。比如有的服务商把结束原因叫finish_reason,有的叫stopReason,还有的在嵌套字段里;有的返回内容里有多个候选文本,有的只有一个。如果统一结构在设计时直接把字段拍平,很容易丢失原始信息。Fold 保留了一个"原始响应"字段,上层需要排查问题时还能回看,这个细节我给了很高的评价。
路由策略是整个多提供商设计的亮点。固定路由、故障转移、延迟优先、成本优先,这几种策略被建模成一个 Effect Service。也就是说,"这次请求应该交给哪个 Provider"这个决策本身是可替换、可测试的。实际运行中,你可以把当前可用 Provider 列表做成动态数据,由配置中心下发,而不是每次发版改代码。一个请求的完整路径大致是:入口拿到用户输入,交给路由 Service 决策,路由从注册表选出一个 Provider,请求再被适配器翻译成对应 SDK 调用。链路中每个环节都可以 mock,这给集成测试省了大力气。
1.3 "Fold" 这个名字想说明什么
名字透露了框架的元理念。数组的reduce方法你肯定熟悉,它把一组元素折叠成一个最终值;Fold 这个名字也是同样用意——把模型、工具、外部上下文、历史消息这些异构元素,折叠进一条类型化的执行管道。每一次模型调用、工具执行、错误重试,都是管道中的一段,最终折叠出用户可以消费的响应。
再有另一层含义:多提供商原本意味着大量的 if/else 分支,每家 SDK 一套调用方式,但 Fold 把这种差异折叠到了适配层,上层主循环不需要感知自己在调用哪一家的模型。代码里主循环往往很短,因为复杂度都被折叠到了边界处,这也是我认为它值得写一篇解读的原因。
2. 核心细节解析与实操要点
2.1 上下文与依赖注入的实现方式
Agent 运行离不开状态:当前消息历史、工具执行结果、还剩多少 token 预算等等。很多框架喜欢把这些状态塞进一个全局单例,短期调试方便,但多用户并发时就会串数据。Fold 的做法是用 Effect 的Context.Tag来声明服务,再通过Layer构建运行依赖。
import { Context, Layer, Effect } from "effect"; interface AgentState { chatHistory: readonly Message[]; toolResults: ReadonlyMap<string, ToolResult>; tokenBudget: number; } class AgentStateService extends Context.Tag("AgentStateService")< AgentStateService, AgentState >() {} const AgentStateLive = Layer.succeed(AgentStateService, { chatHistory: [], toolResults: new Map(), tokenBudget: 4000, });第一段定义状态结构,第二段把状态声明成一个 Effect 服务。第三段的Layer.succeed则是把服务的具体实现注入到运行环境里。整个过程里没有全局变量,Agent 需要的状态来自环境类型。每次运行一个 Agent,你可以给这次运行注入独立的状态实例,多个用户同时跑也不会互相干扰。
实操里要特别注意不可变性。Effect 环境里的状态对象应该是 immutable 的,更新消息历史时不能直接push,而是要返回新数组和新对象。举个例子:
const nextState: AgentState = { ...state, chatHistory: [...state.chatHistory, userMessage], };这段代码每次都会创建新的状态对象,旧的引用不会因为push而被意外改掉。第一次从命令式风格转过来的人,经常在这里踩坑:函数式地更新状态后,发现外层拿到的还是旧对象,原因多半是忘记了 spread 一层再赋值。
2.2 Agent 主循环与工具调用协议
Agent 主循环的骨架并不复杂,我拆解给你看。它本质上是反复执行这样几个步骤:接收用户输入,加入消息历史;把历史、system prompt、工具定义一起发送给模型;解析模型返回,如果模型要求调用工具,就按 Schema 校验参数并执行工具;工具结果是新消息,加入历史,继续下一轮;直到模型返回最终文本或超过最大轮数。
工具定义是关键的协议层。模型并不知道你的工具内部怎么实现,它只看到一份 JSON Schema 描述。Fold 里用@effect/schema来定义工具参数,好处是这套 Schema 既能描述给模型看,又能作为运行时校验器。
import { Schema } from "@effect/schema"; const SearchTool = Schema.Struct({ query: Schema.String, limit: Schema.Number.pipe(Schema.int(), Schema.between(1, 10)), });这里定义了一个搜索工具参数,query 是字符串,limit 是 1 到 10 的整数。不要相信模型返回的参数永远合法。我实测时遇到过模型把 limit 返回成字符串"3"、把字段名拼错、甚至凭空多出一个不存在的过滤条件等情况。不用 Schema 校验,错误参数直接进入业务函数,轻则查询结果异常,重则触发副作用。用Schema.decodeUnknown解码,可以让错误在边界处被拦截。
校验失败后有一个实用技巧:把具体错误信息作为一条新消息回传给模型,让它自我修正。模型第一次返回{ query: "Effect 并发", limit: "3" },解码失败后,系统追加一条"limit 期望数字,实际收到字符串 '3',请修正"的消息,很多模型第二轮就能给正确结果。但要设置最大修正次数,我习惯设成 3 次,再多了容易陷入反复修正的死循环,白白消耗 token。
2.3 会话记忆与外部上下文如何接入
会话记忆分两层。请求内的短时记忆,就是 AgentState 里的 chatHistory,每次工具调用和模型回复都会被追加进去。跨请求的持久化记忆,比如用户上次聊到一半的偏好,Fold 把它抽象成了另一个独立的 Service。
外部上下文接入是另一个重要扩展点。想让 Agent 引用知识库内容,最直接的做法是检索后把结果塞进 system prompt。Fold 把这定义为ContextProvider接口:
interface ContextProvider { readonly retrieve: ( query: string, limit: number ) => Effect<never, Error, readonly AugmentBlock[]>; }只要有这个接口,向量数据库、关键词搜索、甚至固定知识库文件都可以实现。它把"在哪查"和"怎么用"分离了,上层 Agent 只关心拿到一组 AugmentBlock,拼进上下文;底层可以随时替换检索实现,不用改主循环。
实际接入时最容易翻车的点是 token 预算。模型的上下文窗口是有限的,很多人习惯把所有检索片段都塞进 prompt,很快就把窗口撑爆,或者无关内容干扰了模型的注意力。比较稳妥的做法是保留最近几轮对话全文,外部上下文只取 top-K 相关片段,并且按相关度排序。如果业务上还需要更长的记忆,就得考虑摘要压缩或分段裁剪的策略。我第一次做 RAG 增强的 Agent 时,把向量库返回的五十个片段全部拼进去,模型直接开始胡言乱语,后来把片段数量压到五个并只在用户提问相关时注入,效果立刻正常了。
3. 实操过程与核心环节实现
3.1 五分钟跑起一个双提供商示例
理论聊完直接上手。下面我演示一个最小可运行的双提供商 Agent,代码里用"厂商 A"和"厂商 B"作为代号,实际接入时你只需要替换成自己手里的服务商配置。
先准备环境变量:
export FIRST_MODEL_KEY="你的第一个厂商密钥" export SECOND_MODEL_KEY="你的第二个厂商密钥" export FIRST_MODEL_ID="model-x-large" export SECOND_MODEL_ID="model-y-large"密钥不要写进代码仓库,这是基本的底线。环境变量准备好后,安装依赖。框架核心包需要包含 Effect 和 Schema 相关依赖,实际以你获取到的框架包说明为准,这里只列出基础依赖:
npm install effect @effect/schema接下来构建两个 Provider 的注册逻辑。为了节省篇幅,我给出经过简化的注册片段:
import { Context, Layer, Effect } from "effect"; class ProviderRegistry extends Context.Tag("ProviderRegistry")< ProviderRegistry, ProviderRegistry >() {} const FirstProvider = Layer.effect( ProviderRegistry, Effect.gen(function* () { const sdk = yield* createSdkLike({ apiKey: process.env.FIRST_MODEL_KEY ?? "", }); return createProviderRecord({ sdk, modelId: process.env.FIRST_MODEL_ID ?? "", }); }) );createSdkLike和createProviderRecord是框架适配层的辅助函数,它们的作用是把真实 SDK 包装成一个统一 Provider。你实际使用时,这里往往拿到的是框架为具体服务商写好的适配器,不需要自己从零实现。
之后就是 Agent 请求的入口。下面这段代码会请求路由层按故障转移策略选出一个 Provider,然后让 Provider 执行补全:
const program = Effect.gen(function* () { const registry = yield* ProviderRegistry; const provider = yield* router.route(registry, { strategy: "failover" }); const result = yield* provider.complete({ messages: [{ role: "user", content: "用一句话介绍 TypeScript 并发" }], }); return result.text; });这里的关键是router.route返回的是一个 Provider,而这个 Provider 背后可能是厂商 A 也可能是厂商 B。第一次运行,厂商 A 正常时,返回结果来自 A;如果你把厂商 A 的密钥故意配错,或者模拟超时,路由层会在报错后自动切换到厂商 B,上层代码一行都不用改。这个能力在实际生产里非常值钱,尤其是模型服务不稳定或者单家限流的时段。
3.2 结构化输出:让模型返回可靠 JSON
大模型输出的结构化问题,是做 Agent 绕不过去的坎。比如你想从用户一句话里抽取订单信息,最直接的办法是让模型输出 JSON,然后JSON.parse。但模型输出的 JSON 经常不合法,字段类型也偶尔对不上。正确处理方式是三层防护:预期 Schema、解码校验、失败回传。
先定义 Schema:
const OrderInfo = Schema.Struct({ orderNo: Schema.String, userId: Schema.String, amount: Schema.Number, currency: Schema.Union( Schema.Literal("CNY"), Schema.Literal("USD") ), });模型返回的文本先不急着 parse,而是用一个"要求只输出 JSON"的 prompt 约束它。拿到文本后,用Schema.decodeUnknown(OrderInfo)(text)去解码。成功时,你得到一个类型完全确定的OrderInfo对象;失败时,错误对象里会明确说哪个字段没有通过校验。
有一次我测试时,模型把amount输出成了字符串"88.5",如果直接JSON.parse,这个错误会被偷偷带进下游的计费逻辑。用了 Schema 解码,错误当场暴露。随后把错误信息打印给模型:"amount 期望数字类型,实际收到字符串 '88.5',请重新输出 JSON"。模型往往很快就能修正。这样的策略比盲目要求模型"请一定要正确输出"有效得多,因为模型能根据具体错误提示调整。
还值得注意一个实践技巧:工具 Schema 和 prompt 里的描述最好同源。你在@effect/schema里定义的结构,可以通过序列化转成 JSON Schema 描述,直接拼进 prompt。这样就不用维护"给模型看的描述"和"给代码用的校验器"两份内容,只要改一处,两边同步生效。
3.3 流式输出、超时与中断
流式输出是 Agent 产品体验的重要部分。Fold 用 Effect 的 Stream 来表达 token 流。每个 chunk 到来时推送给前端,用户可以感知到模型"正在打字"。这个能力在 Web 场景里几乎是必需品,没有人愿意等一个完整响应拼完再显示。
import { Stream, Effect, Fiber } from "effect"; const stream = provider.stream({ messages, tools, }); const fiber = yield* Effect.fork(streamPipeline);Effect.fork会把流处理管线放到一个独立的 Fiber 里运行。用户中途点击"停止生成"时,不是抛个异常假意退场,而是真正中断正在执行的任务:
yield* Fiber.interrupt(fiber);中断会尽可能清理底层资源,避免后台请求继续跑导致资源泄漏。超时控制也是从这里引申出来的。一个完整请求不应无限期地等模型回复,用Effect.timeout设置上限是标准做法:
const result = yield* provider.complete(request).pipe( Effect.timeout("30s"), Effect.retry({ times: 2, delay: "500ms" }) );这里加了 30 秒超时,超时后任务会被中断,不会挂死。实操中有一个容易忽略的细节:流式模式下,如果只有一个连接建立超时,后续 chunk 却一直不来,客户端就卡住了。超时控制要覆盖到整个流的生命周期,而不只是连接建立那一刻。最好在流管道的末端也挂上总超时,确保从第一个 chunk 到最后一个 chunk 的完整链路都有兜底。timeout 的取值也要看模型规模,小模型 20 秒足够,长文本生成的模型可能要 60 秒甚至更多,建议按模型单独配置,不搞一刀切。
4. 常见问题与排查技巧实录
4.1 不同提供商返回的错误结构不一致
多提供商接入后,你首先会发现一个现实:各家 SDK 抛出来的错误对象五花八门。有的把错误信息放在error.message,有的放在error.error.message,还有的藏在error.data.details。如果上层代码直接处理这些结构,错误处理逻辑就会变成一串难看的类型判断。
我的建议是在适配器出口把所有 Provider 错误归一化为一个只包含必要字段的联合类型:
type ProviderError = | { _tag: "RateLimit"; retryAfter?: number } | { _tag: "Timeout"; timeoutMs: number } | { _tag: "BadRequest"; message: string };上层只需要按_tag分类处理:限流就等retryAfter再重试或切换;超时就走故障转移;参数错误直接返回给调用方,不无脑重试。归一化时注意留一个originalError字段保留原始信息。我以前图省事只保留文案,结果线上出问题后看不到任何现场信息,排查效率大打折扣。保留原始错误不是妥协,是给未来的自己留一条退路。
4.2 工具调用参数校验失败或幻觉字段
模型返回的工具调用参数里出现幻觉字段,或者字段类型不对,是 Agent 开发里出现频率最高的问题之一。我遇到过模型把limit写成"3",遇到过把不存在的筛选条件编造出来,甚至把 query 拼成了一段完整的问题而不是关键词。
处理流程分三步。第一步,解码前保留原始的模型输出,不要直接丢。第二步,把校验错误的具体字段和期望类型,作为一条新系统消息回传给模型,让它自行修正。第三步,设置最大修正次数,一般 2 到 3 次。超过次数仍然失败,就返回兜底回复,不再硬试。
这里最核心的经验是"错误信息必须具体到字段级别"。模型不是神,你说"格式错误请重试",它只能瞎猜;你说"limit 字段期望数字,实际收到字符串 '3'",它才能准确修改。我见过不少团队在模型修正环节反复失败,不是因为模型能力不足,而是反馈信息太含糊。
4.3 上下文环境混乱和 Provider 多次初始化
多用户并发时,最容易出现两个让人抓狂的现象:一个是 A 用户修改的数据影响到了 B 用户的会话,另一个是 Provider 客户端被反复新建,底层连接池被耗尽。前者多半是因为把 AgentState 建成了全局单例;后者则是因为在请求处理函数内部不断执行 Provider 的构造逻辑。
正确的做法是分清应用级和请求级两层依赖。Provider 客户端、路由配置属于应用级资源,进程启动时构建一次,放进全局的运行时里。AgentState 和会话历史属于请求级资源,每个请求进来时创建独立实例。Effect 的 Layer 天然适合这种区分:全局 Layer 作为外层,请求级状态作为内层,运行时自动处理生命周期。一台服务器上几百个并发请求,如果每个请求都新建一次 SDK 客户端,延迟会肉眼可见地涨。把客户端改成启动时构建一次之后,延迟能立刻降下来。
4.4 超时、限流与重试导致的雪崩
最后一个问题我留给稳定性。模型服务商一旦出现故障,表现往往是响应变慢而不是立即报错。如果客户端设置的重试策略很激进,所有请求都卡在等待里,新的请求还在不断进来,整个服务很快被拖垮。
重试不是哪里都能用的。参数错误不重试,认证错误不重试,只有限流、超时、5xx 这类临时故障才值得重试。重试间隔要用指数退避加抖动,避免大量请求在同一个时间点同时重试,形成惊群效应。另一个实用经验是:一个 Agent 请求内部可能包含多次模型调用和工具调用,不要给每次子调用都设置独立的超时再叠加,否则总时长会失控。要给整个请求一个总预算。
如果配置了多提供商,故障转移也是一种缓解手段。可以连续记录每个 Provider 的成功率和错误类型,达到阈值就把它临时标记为不健康,让路由层避开它。过一段时间再放回来探活。只依赖超时重试,往往要等到请求全部失败才知道出问题的是哪一家,而预先埋一个熔断机制,就能在问题扩大之前把流量导走。
就我个人跑这个框架的感受来说,最值钱的不是某一个具体的路由策略或校验工具,而是整套链路把"模型调用"从散落的副作用变成了可以组合、可以替换、可以观测的类型化管道。日常开发里最难受的其实不是写功能,而是那些无法预期的失败和无法管理的依赖。如果你想在现有项目里引入多提供商支持,不用一步到位,可以先从统一 Provider 接口开始,把其中一家接入,再逐步扩展。这个起点很小,但它的价值会在你切换模型或排查故障的那一刻体现出来。