
最近在评估 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 的核心思路是把副作用变成可以被组合的值一次模型调用在类型层面被描述为EffectEnvironment, 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: ReadonlyMapstring, 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 ) Effectnever, 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_IDmodel-x-large export SECOND_MODEL_IDmodel-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、解码校验、失败回传。先定义 Schemaconst 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 接口开始把其中一家接入再逐步扩展。这个起点很小但它的价值会在你切换模型或排查故障的那一刻体现出来。