news 2026/10/22 5:00:56

基于Effect构建Agent层:Fold框架的工程化实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Effect构建Agent层:Fold框架的工程化实践

先说结论:如果你已经在用 Effect 写 TypeScript,又想在模型调用之上搭建一层可靠、可组合的 Agent 层,那么 Fold 很可能是目前最贴合你习惯的选择。Fold 是一个以 Effect 为底座、面向多提供商接入的 Agent 核心框架,它不追求一键生成花哨的对话机器人,而是把“模型调用、工具调度、上下文管理、流式响应”这些基础能力,全部用 Effect 的类型和运行时语义重新实现了一遍。这篇文章我会从架构、核心抽象、多提供商适配讲到最小实战项目,适合那些已经对 Effect 有基本了解、正准备自建 Agent 基础设施的读者。

为什么我会盯上它?因为过去半年我在不同项目里尝试过好几种 Agent 方案,最后发现一个共性问题:很多框架把“快速跑通”放在第一位,把“工程质量”放在第二位。等到你真的要把 Agent 接入业务系统,要处理工具调用的失败重试、多个模型供应商的协议差异、长任务的上下文膨胀时,这种框架往往会成为新的包袱。而 Fold 的路数完全不同,它把副作用建模、错误处理、依赖注入这些工程问题放在最前面,框架本身只是薄薄一层。

1. 为什么我已经有十几个 Agent 框架,还要为 Effect 单独选一个 Fold

1.1 现有 Agent 框架的两条路线与共同痛点

现在市面上的 Agent 框架大致分两类。第一类是偏应用层的,追求开箱即用,给你封装好对话界面、知识库、插件市场,配置几个参数就能跑出一个演示 Demo。这类框架的问题是有时候太“重”,你很难把它的内部循环抠出来嵌入到你自己的业务流程里。

第二类是偏底层的,给你模型客户端、消息结构、工具注册表,其余都靠自己拼。这类框架看起来灵活,但我实际用下来发现,它们的“灵活”往往建立在非常弱的抽象上:类型推导基本靠 any,错误处理靠到处 throw,依赖注入靠全局单例,并发控制靠 Promise.all。一旦 Agent 的工具链超过 3 个,循环里再嵌套几个流式响应,代码很快就变成一团浆糊。

我并不是说这些框架不好,它们各自解决了很多实际问题。但如果你本身就是 Effect 生态的用户,你会明显感觉到这种“断裂”:Effect 给你建立了类型安全的副作用管道、结构化并发和可恢复错误模型,可一进入 Agent 层,这些东西全丢了,你又回到了 try-catch 和全局变量的世界。

1.2 Effect 到底给 Agent 带来了什么

简单说,Effect 是把“程序执行”本身变成一种可组合的值。副作用不再是执行到一半突然抛异常的黑盒,而是一个带有类型参数的值,你可以对它做超时、重试、并发、依赖注入、资源清理,所有这些操作在编译期就有迹可循。

这对 Agent 框架来说几乎是最理想的地基。Agent 的本质就是一个循环:把用户输入喂给模型,模型返回文本或工具调用,执行工具,把结果再加回去,继续请求模型。这个循环里天然存在超时、限流、工具失败、上下文超长、并发执行多个工具、中断恢复等问题。用 Effect 来表达这个循环,每一条路径都落在类型系统里,而 Fold 做的,就是把这一层循环及其周边能力完整地建立在 Effect 语义之上。

这也是“Effect 原生”和“用 Effect 写了个壳”的区别。我见过一些框架,确实用了 Effect 的函数,但内部还是靠可变全局变量传递状态,错误处理还是直接 reject。Fold 不一样,它从 Provider 接口到 Agent 运行时,边界、错误、上下文、资源清理都沿用 Effect 的语义,整个调用链是干净的。

1.3 一张对比表:常见框架的普遍处理 vs Fold 的处理

我在迁移过程中整理过一个简表,可以直观说明问题:

维度常见框架的普遍处理Fold 的处理
类型安全消息和工具参数经常是宽松对象,运行时才报错工具输入输出有 schema,Agent 副作用类型显式标注
错误处理throw / try-catch 散落各处统一用 Effect 的错误通道,可 catchTag 分类恢复
依赖注入环境变量 + 全局单例基于 Effect 的 Layer 和 Context,依赖显式组合
并发控制Promise.all 一把梭Fiber 结构化并发,子任务随主任务自动取消
资源清理流式响应容易泄漏连接Scope 管理生命周期,关闭即回收
可观测性自定义日志,各不相同接入 Effect Tracer,自动生成 span 和 token 计数

这张表不是说要否定其他框架,而是如果你吃透了 Effect 的理念,再回到普通框架会感到束手束脚。

1.4 适合谁用,不适合谁用

Fold 不适合那种只想快速搭一个问答机器人、不想管底层细节的用户。它适合三类人:一是已经有 Effect 项目、想在其中加入 Agent 能力的团队;二是需要自行对接多家模型供应商、希望供应商差异被隔离在适配层的基础设施开发者;三是被复杂工具链困扰、想用强类型约束把 Agent 循环做扎实的资深工程师。

2. Fold 的整体架构:从 Provider 到 Agent 运行时的一条清晰主线

2.1 三大分层:先分清“接模型”和“跑脑回路”

Fold 的整体架构可以拆成三层。最底层是 Provider 层,它负责跟具体的模型服务打交道,把不同供应商的 HTTP 接口、鉴权方式、流式格式统一成一个内部接口。中间是 Agent 核心层,它不关心你用的是哪家模型,只负责维护消息状态、调度工具调用、执行循环策略。最上面是应用接口层,给调用方一个简洁的 Agent Handle,你只需要传入用户输入,拿到文本或流式结果。

这个分层思路并不稀奇,稀奇的是每一层之间的边界是用 Effect 的 Effect 类型严格划定的。你可以把 Provider 层看成“水管供应商”,把 Agent 核心层看成“水龙头”,用户不会关心水是从水库来的还是从净水器来的,但水管供应商必须遵守同一个接口规格。

2.2 核心类型:Agent 泛型里藏着三个关键参数

Fold 里最核心的类型是Agent<R, E, I, O>。R 代表依赖,也就是这个 Agent 运行需要哪些 Context 中的服务,通常包括 Provider、Logger、Tracer 等;E 代表可能出现的错误类型,比如 ProviderError、ToolExecutionError、ContextOverflowError;I 和 O 代表输入和输出的消息流类型。

这个设计的价值在于“可组合性”。你可以定义一个没有具体 Provider 的 Agent,只描述它的工具和提示词逻辑,然后通过Layer注入不同的 Provider,从而在不同环境复用同一套 Agent 定义。这在多提供商场景里特别有用:测试时注入 mock Provider,生产时注入真实供应商,类型检查会确保两边都有齐所需依赖。

2.3 Effect 的 Scope 和 Layer 在框架里扮演什么角色

解释一下为什么这两个机制对 Agent 框架很重要。Scope 负责资源的“有始有终”,比如流式响应底层是一条网络连接,如果你消费到一半不再需要了,Fold 会借助 Scope 自动关闭底层连接,而不是等垃圾回收。Layer 则负责依赖的组装,你可以把 API Key、模型路由规则、超时配置打包成一个 Layer,App 启动时组装一次,之后每个 Agent 调用从这个 Layer 里取配置。

我在实际使用中最大的感受是:出错时你只需要看类型,就能判断是 Provider 的问题还是工具的问题,因为错误类型被显式区分开了。这在排查多提供商 Agent 的线上问题时非常舒服。

3. 核心抽象:Tool、调用循环与上下文管理

3.1 一个工具到底怎么定义

Fold 里的工具不是简单的一个函数,它由三部分组成:名字、输入描述、执行函数。输入描述用普通对象声明,框架会把它编译成各种模型服务商需要的 JSON Schema 格式。执行函数返回一个 Effect,这样工具内部也可以使用依赖注入、超时、重试。

举个例子,我定义一个“获取指定时区当前时间”的工具:

import { Effect } from "effect" import { Fold } from "@fold/core" const currentTime = Fold.tool({ name: "current_time", description: "获取指定时区的当前日期和时间", input: { timezone: { type: "string", optional: true, description: "IANA 时区名称,例如 Asia/Tokyo", }, }, run: ({ timezone }) => Effect.tryPromise(async () => { const tz = timezone ?? "Asia/Shanghai" return new Intl.DateTimeFormat("zh-CN", { timeZone: tz, dateStyle: "full", timeStyle: "long", }).format(new Date()) }), })

这里最值得注意的一点是:run返回的是Effect,而不是直接返回字符串。这意味着你可以在工具内部做网络请求、读数据库、调用内部服务,而 Fold 可以把这些工具执行过程纳入总的错误模型和追踪体系。

3.2 调用循环:读懂下面这段伪代码就懂了 Agent

Agent 的核心就是一个循环,我用简化过的 TypeScript 描述一下它的骨架:

import { Effect, Ref } from "effect" import * as Fold from "@fold/core" export const runAgent = (agent: Fold.Agent, input: string) => Effect.gen(function* () { const messages = yield* Ref.make<Fold.ChatMessage[]>([ Fold.userMessage(input), ]) for (let i = 0; i < 10; i++) { const reply = yield* agent.chat(yield* Ref.get(messages)) yield* Ref.update(messages, (msgs) => [...msgs, reply]) const calls = Fold.toolCallsOf(reply) if (calls.length === 0) { return Fold.textOf(reply) } const results = yield* Effect.all( // 并行执行多个工具 calls.map((call) => agent.executeTool(call)), ) yield* Ref.update(messages, (msgs) => [ ...msgs, ...results.flat(), ]) } return yield* Effect.fail( new Fold.StopReason("最大迭代次数已超出"), ) })

注意Effect.all这一行。如果模型在一次回复里同时要求调用两个工具,Fold 会并行执行它们,但每一个工具都在同一个 Fiber 树下面,任何一个工具失败,或者外层调用被取消,所有工具都会一起收掉。这就是结构化并发在 Agent 循环里最直接的价值。

3.3 上下文管理:窗口、摘要、外部记忆

上下文膨胀是 Agent 落地时最常遇到的问题。Fold 内置的策略有三种:窗口截断、摘要压缩、外部记忆。窗口截断最简单,只保留最近 N 条消息;摘要压缩是在超长时调用一次模型生成历史摘要;外部记忆则允许你把历史消息存进外部存储,需要时再按语义检索。

我自己的建议是:默认先用窗口截断,因为它不会引入额外的模型调用成本。摘要压缩虽然效果好,但要小心它本身就会消耗 token,并且在摘要过程中可能丢掉工具调用细节,导致 Agent 下一轮“失忆”。外部记忆适合做长期对话,但接口抽象要提前设计好。

3.4 循环中 Effect 给开发者的三个具体能力

一是超时。整个 Agent 循环可以用Effect.timeout包住,超过设定时间就自动终止,不会出现一个卡死的模型请求挂住整个服务。二是分步重试。工具调用失败可以针对具体错误标签做重试,比如网络超时重试三次,而参数校验失败直接返回给模型纠正,而不是盲目重试。三是中断恢复。Effect 的错误通道可以把 Agent 中途的状态保存下来,下次从断点继续,而不是从头开始烧一遍 token。

4. 多提供商适配:怎么用同一套类型接住不同厂商的差异

4.1 Provider 的最小接口

Fold 的最小 Provider 接口只有两个方法:chat和stream。chat接收标准化的消息列表和参数,返回一个完整的回复;stream返回一个异步迭代器,调用方可以逐块消费文本和工具调用。

在实现层面,每个适配器的职责就是把内部标准格式翻译成对应供应商的协议格式,再把响应翻译回内部标准格式。适配层里可以放心地写实现细节:鉴权头、endpoint、超时时间、SSE 解析逻辑,全部隔离在这个文件里,Agent 核心层完全不用关心。

4.2 适配器需要消化的五类常见差异

对做过模型接入的人来说,这些差异应该深有体会。

工具调用字段名各不一样。有的供应商叫functions,有的叫tools,有的叫actions,参数结构也各不相同。适配器必须在请求发出前完成字段映射。

工具参数格式五花八门。有的返回结构化对象,有的返回一截 JSON 字符串,更麻烦的是流式场景下参数可能被拆成多个 chunk,需要拼接后再解析。

流式协议不统一。大多数走 SSE,但事件命名不一样,有的是data:,有的带delta,有的用自定义event:。适配器要统一成内部流格式。

限流和错误信息格式不一样。有的返回 429 带Retry-After,有的返回 200 但 body 里带错误码。Fold 的适配器会把这些归一化为RateLimitError、AuthError、BadRequestError等类型。

模型能力参差不齐。有的支持并行工具调用,有的只支持串行,有的甚至不支持工具调用。适配器需要上报能力位,Fold 根据能力位自动切换策略。

4.3 差异对照表与处理策略

差异点常见情况Fold 适配策略
工具调用字段名functions / tools / actions适配器统一转换为内部标准字段
工具参数格式对象、JSON 字符串、流式半截 JSON统一解析为对象,内置半截修复器
流式格式SSE、WebSocket 轮询统一为 Effect Stream,消费完自动关闭
错误响应HTTP 状态码、业务错误码混杂归一化为 Effect 错误类型,分类可 catch
并行工具支持支持 / 不支持 / 只能串行读取能力位,运行时自动调整调度方式

4.4 多模型路由与降级

Fold 的另一个多提供商场景是“路由”。你可以给同一个 Agent 配置多个供应商,按规则选择:简单任务走便宜模型,复杂任务走强模型,主供应商限流时自动切到备用。这一点在目前大多数框架里做得很粗,要么只能写死一个模型,要么只有简单的 try-catch 切换。

路由规则在 Fold 里是一个普通的 Effect 值,你可以自由判断输入消息长度、意图类型、当前配额,甚至用一个小模型先做意图分类再路由到大模型。这才是多提供商框架该有的样子:提供商是资源,不是绑定。

5. 实操:用 Fold 搭一个能调用工具的最小 Agent

5.1 先安装依赖

下面用 npm 安装,实际上用你熟悉的包管理器都可以。

npm install @fold/core @fold/adapters effect

这里@fold/core是核心库,@fold/adapters是各供应商适配器,effect是运行时依赖。版本都装最新稳定版即可。

5.2 定义工具与 Provider

我沿用上面的currentTime工具,再配置一个“供应商 A”的适配器。为了安全,API Key 从环境变量读取,不写进代码。

import { Effect, Layer } from "effect" import { Fold } from "@fold/core" import { adapterA } from "@fold/adapters" const TimeTool = Fold.tool({ name: "current_time", description: "获取指定时区的当前日期和时间", input: { timezone: { type: "string", optional: true }, }, run: ({ timezone }) => Effect.sync(() => new Intl.DateTimeFormat("zh-CN", { timeZone: timezone ?? "Asia/Shanghai", dateStyle: "full", timeStyle: "long", }).format(new Date()), ), })

Provider 配置我单独写一个文件,方便测试时替换成 mock:

const ProviderLive = Layer.succeed( Fold.Provider, adapterA.chatModel({ model: "vendorA-chat-medium", baseURL: "https://api.example-vendor.com/v1", apiKey: process.env.AI_API_KEY ?? "", }), )

vendorA-chat-medium只是一个示例模型名。实际接哪个供应商,以适配器文档为准。

5.3 组装 Agent 并运行

接下来创建一个带提示词、带工具的 Agent:

const AgentLive = Fold.Agent.make({ name: "timeAssistant", prompt: "你是一个时间助手。涉及日期、时区、当前时间的问题,必须使用 current_time 工具回答。", tools: [TimeTool], }).pipe( Fold.provide(ProviderLive), ) const program = AgentLive.run("现在东京时间是几点?")

调用program时,Fold 会依次完成:组装消息、请求模型、发现工具调用、执行工具、把结果追加回消息列表、再次请求模型、输出最终文本。你不需要手动维护循环,也不需要拼接 JSON,这些都封装在run里。

5.4 执行与验证

执行上面这段程序,我加了一点调试日志,实际看到的典型流程是:

  • 请求发送后先拿到模型返回的空文本和toolCalls: [{ name: "current_time", args: { timezone: "Asia/Tokyo" } }]
  • 工具函数执行,返回格式化后的东京时间字符串
  • 模型基于工具结果生成自然语言回答:“现在是……东京时间……”
  • 最终输出文本给用户

如果模型不支持工具调用,Fold 会走降级路径:把工具描述和可用工具列表写进系统提示词,然后解析模型输出里的结构化指令,也能勉强工作,但可靠性会下降。所以我建议直接选支持工具调用的模型。

5.5 实操中立刻会遇到的异常

我头几次跑就遇到两个问题。一个是没有给 Provider 设置超时,某次上游服务异常,请求挂了两分钟才返回,直接拖垮了所有并发。后来我给chat包了一层Effect.timeout(30_000)。另一个是工具参数校验,模型生成了一个带不正确时区的参数,工具执行直接抛异常。解决方法是给工具参数加枚举校验,并把校验错误反馈给模型,让它自己纠正。

6. 逃不掉的坑:流式、重试、可观测性与成本

6.1 流式消费的 Scope 问题

流式输出看起来方便,实际上最容易泄漏资源。如果你消费流式响应时没有正确关闭底层连接,连续跑几十次之后会出现连接数暴涨。Fold 的流式是基于 Effect Scope 的,通常你在Effect.scoped里消费流,退出作用域时自动关闭。但如果你把流对象从 Effect 里取出来丢给别的地方消费,作用域边界就不靠谱了。

我建议所有流式结果都留在 Effect 管道内消费,哪怕是先收集到数组再返回,也不要直接暴露原始流给上层。

6.2 重试与幂等是两件事

Agent 循环里重试要特别小心。模型请求失败重试是安全的,因为请求本身没有副作用,但工具调用不一定。一个“创建订单”的工具如果被网络超时后重试两次,可能就下了两笔订单。Fold 不会魔法般替你判断幂等,它只是提供重试原语,把决定权留给你:只有标记为幂等的工具才允许自动重试。

我在实现里约定:所有写操作工具默认不重试,所有读操作工具默认最多重试两次。这个约定在团队里形成规范后,线上少了很多幺蛾子。

6.3 可观测性:token 和 span 默认就有

Fold 接入 Effect Tracer 后,每次 Agent 调用会自动生成一个 span,里面带 Provider、model、工具调用次数和 token 消耗。排查问题的时候,我习惯把 span 导出到观测平台,按“Agent 名称 + 供应商”维度的耗时可排序,一下就能看出哪个 Agent 在烧钱。

如果你现在还裸写,没有任何追踪,建议至少记录这几样:请求模型名、工具名、输入 token、输出 token、工具执行耗时、循环轮数。

6.4 成本控制:一个死循环的 token 炸弹

最容易被忽视的是 Agent 循环本身会反复消耗 token。如果模型连续几次都返回同样的错误工具调用,循环会一直转,直到撞上最大迭代数,而每一轮都在烧钱。所以成本控制要从三个层面同时做:最大迭代数、单轮输出 token 上限、工具结果长度限制。

工具返回大量数据时尤其危险。比如数据库工具返回了几百行记录,全部塞进上下文里,下一轮请求的输入 token 直接起飞。我的做法是给工具包一层“结果摘要”,大结果只返回前几条和总计行数,让模型决定是否需要更多数据。

6.5 一点个人经验

在我自建 Agent 基础设施的这段时间里,最深的体会是:Agent 框架的价值不在“模型调得好”,而在“循环管得住”。Fold 这种基于 Effect 的设计,把循环里的每一个不稳定因素都显式化成类型和策略,你可以在编译期看到错误路径,而不是等线上崩溃再抓日志。它也牺牲了一些上手速度,但对于长期维护的中大型项目,这个代价是值得的。如果你已经在 Effect 生态里,不妨从一个小工具调用开始试试,它会改变你对 Agent 工程化这件事的预期。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 5:00:49

多轮对话Agent的状态跟踪:从DST原理到LLM工程实践

我最早做对话型Agent的时候&#xff0c;吃过一次很没面子的亏。用户在订餐助手那边连续说了两句&#xff1a;帮我订周五晚上8点&#xff0c;三个人&#xff0c;市中心老牌粤菜&#xff0c;然后紧接着补了一句提前一小时吧。助手直接懵了——因为它把第二轮当成了一次全新的请求…

作者头像 李华
网站建设 2026/10/22 5:00:21

手写Tool Use三套方案:从字符串解析到ReAct循环

1. 面试现场还原&#xff1a;Tool Use 到底在考什么那天面试官问题一出来&#xff0c;我脑子里的第一反应不是“怎么写”&#xff0c;而是“他到底想听我怎么讲”。原话大概是&#xff1a;“既然简历里写了做过 Agent&#xff0c;那现在不查文档、不联网&#xff0c;你手写一个…

作者头像 李华
网站建设 2026/10/22 5:00:21

静磁场仿真加速:用机器学习代理模型实现毫秒级场预测

做电磁仿真的人应该都有同感&#xff1a;静磁场分析虽然理论成熟、公式简洁&#xff0c;但一旦模型里出现复杂几何、非线性磁导率或者大规模三维结构&#xff0c;单次有限元求解常常要等几分钟甚至更久。参数扫描和优化设计的时候&#xff0c;成百上千个工况点排队等着算&#…

作者头像 李华
网站建设 2026/10/22 4:59:39

开源CRM Twenty:为AI Agent打造数据底座与自托管实战

开头先聊个有意思的现象&#xff1a;开源CRM领域一直不缺新面孔&#xff0c;但能在一两年内冲到5.8万星的&#xff0c;确实不多。Twenty这个项目&#xff0c;我第一次看到这个名字还以为是个新出的前端框架&#xff0c;点进去才发现是个伪装成Notion风格的现代CRM。说来也巧&am…

作者头像 李华
网站建设 2026/10/22 4:59:25

AI文档处理避坑指南:为什么必须先转Markdown再输入

1. 为什么“直接丢文件给AI”是多数人踩的第一个坑我见过太多人把一份20页的PDF报告、一个带格式的Word合同、甚至是一张扫描件截图&#xff0c;直接拖进对话框里&#xff0c;点下发送&#xff0c;然后盯着屏幕等AI“读懂它”。结果呢&#xff1f;要么AI说“文件过大无法处理”…

作者头像 李华
网站建设 2026/10/22 4:58:44

随机森林核心参数与实战调优指南

1. 随机森林为什么值得单独开一讲1.1 从决策树的痛点说起做机器学习项目的人&#xff0c;绝大多数都经历过这样的阶段&#xff1a;一开始用逻辑回归、线性回归&#xff0c;发现处理不了特征之间的非线性关系&#xff1b;然后换单棵决策树&#xff0c;指标确实上去了&#xff0c…

作者头像 李华