V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
• 请不要在回答技术问题时复制粘贴 AI 生成的内容
CodeCaster
V2EX  ›  程序员

最近看 Coze Eino,对比下我们做的 FEL 框架,想听听大家意见

  •  
  •   CodeCaster · 16 小时 0 分钟前 · 513 次点击

    最近看到 Coze 开源代码里的 Eino 框架挺火的,它基于 Go 语言实现,拥有组件化设计和图编排能力,能有效提高 AI 应用的开发效率。Eino 的核心理念是通过组件抽象和图结构编排,解决大模型应用中的复杂流程控制、流式处理和状态管理问题。

    正好我们团队也在开发一个 Java 生态的 FEL 框架,因其主打简洁流畅的任务编排能力,获得了一定关注。FEL 框架不追求炫酷的图结构,而是专注于让开发者用最自然的方式定义 AI 工作流。

    我们和 Eino 团队瞄准的都是提升 AI 应用的开发效率与可维护性,但我们的 FEL 框架和 Eino 框架走的是两条截然不同的路。

    所以就写了一个对比文章,从代码示例出发,介绍一下 FEL 框架与 Eino 不同的编排设计哲学,同时也很想听听大家的意见和建议。

    一、简单场景:入门流程演示

    让我们先来看一个最基础的对话流程:

    ┌─────┐    ┌────────┐    ┌───────┐    ┌─────┐
    │start│───→│ prompt │───→│ model │───→│ end │
    └─────┘    └────────┘    └───────┘    └─────┘
    

    这几乎是所有 LLM 应用的起点。

    ✅ Eino 的做法:链式 or 图式

    Eino 提供了三种编排方式:ChainGraphWorkflow。对于这个简单场景,推荐使用 Chain

    chain, _ := NewChain[map[string]any, *Message]().
               AppendChatTemplate(prompt).
               AppendChatModel(model).
               Compile(ctx)
    chain.Invoke(ctx, map[string]any{"query": "what's your name?"})
    

    干净利落,链式调用清晰表达了执行顺序。

    如果使用 Graph 方式,可以获得更高自由度,但是代码会变得复杂:

    graph := NewGraph[map[string]any, *schema.Message]()
    
    _ = graph.AddChatTemplateNode("node_template", chatTpl)
    _ = graph.AddChatModelNode("node_model", chatModel)
    
    _ = graph.AddEdge(START, "node_template")
    _ = graph.AddEdge("node_template", "node_model")
    _ = graph.AddEdge("node_model", END)
    
    compiledGraph, err := graph.Compile(ctx)
    if err != nil {
        return err
    }
    compiledGraph.Invoke(ctx, map[string]any{"query":"what's your name?"})
    

    多了节点命名、显式连边等操作——冗余感上升的代价,换来了更高的自由度。

    ✅ FEL 的做法:贴近描述业务的写法

    FEL 坚持“大道至简”,只提供一种 Fluent API 风格的流程定义,即使是新手,也能快速搭建基础流程:

    AiProcessFlow<Tip, String> flow = AiFlows.<Tip>create()
        .prompt(Prompts.human("question: {query}"))
        .generate(model)
        .reduce(() -> "", (acc, chunk) -> acc += chunk.text())
        .close();
    
    flow.converse().offer(Tip.from("query", "what's your name?"));
    

    流程定义没有图、节点 ID 或 start/end 标记,整个调用链像一条自然的语言流水线,更贴近业务描述。

    在复杂场景下,FEL 的这种设计优势更加明显:即便流程包含多步生成、多模型协作和流式处理,代码仍然保持简洁,开发者可以更专注于业务逻辑。

    这种设计的背后,是一种以开发者体验为中心的理念:只需要专注开发业务本身,就能写出可靠的 AI 流程。

    二、进阶挑战:优雅的条件分支

    真实世界的应用从来不是一条直线。加入条件判断后,可以进一步校验编排能力。

    我们扩展一下需求:

    ┌─────┐    ┌────────┐    ┌───────┐    ┌──────────┐
    │start│───→│ prompt │───→│ model │───→│need log? │
    └─────┘    └────────┘    └───────┘    └─────┬────┘
                                                │
                                       ┌────────┼────────┐
                                       │Yes     │No      │
                                       ▼        ▼        │
                                  ┌────────┐   ┌─────┐   │
                                  │log     │──→│ end │◄──┘
                                  └────────┘   └─────┘
    

    在模型输出后,判断是否需要记录日志。如果需要,则调用日志函数;否则直接返回。

    Eino:两种路径选择,同一套实现逻辑

    无论是 Chain 还是 Graph ,Eino 都依赖“条件函数返回目标节点名”的机制来实现跳转。

    Chain 写法:

    branchCond := func(ctx context.Context, input *schema.Message) (string, error) {
        if isNeedLog(input) {
            return "log", nil
        }
    
        return "else", nil
    }
    
    log := compose.InvokableLambda(func(ctx context.Context, input *schema.Message) (*schema.Message, error) {
        log(input)
        return input, nil
    })
    elseBranch := compose.InvokableLambda(func(ctx context.Context, input *schema.Message) (string, error) {
        return input, nil
    })
    chain, _ := NewChain[map[string]any, *schema.Message]().
               AppendChatTemplate(prompt).
               AppendChatModel(model).
               AppendBranch(compose.NewChainBranch(branchCond).AddLambda("log", log).AddLambda("else", elseBranch))
               Compile(ctx)
    chain.Invoke(ctx, map[string]any{"query": "what's your name?"})
    

    Graph 写法:

    branchCond := func(ctx context.Context, input *schema.Message) (string, error) {
        if isNeedLog(input) {
            return "node_log", nil
        }
    
        return compose.END, nil
    }
    
    graph := NewGraph[map[string]any, *schema.Message]()
    
    _ = graph.AddChatTemplateNode("node_template", chatTpl)
    _ = graph.AddChatModelNode("node_model", chatModel)
    _ = graph.AddLambdaNode("node_log", log)
    
    _ = graph.AddEdge(START, "node_template")
    _ = graph.AddEdge("node_template", "node_model")
    _ = graph.AddBranch("node_model", branchCond)
    _ = graph.AddEdge("node_log", END)
    
    compiledGraph, err := graph.Compile(ctx)
    if err != nil {
        return err
    }
    compiledGraph.Invoke(ctx, map[string]any{"query": "what's your name?"})
    

    可以看到,虽然整体表现方式不同,但是条件分支的核心逻辑一致:通过字符串匹配决定流向。

    优点是灵活性高,支持任意拓扑;缺点也很明显——字符串硬编码易出错,调试困难。一旦拼错节点名,运行时才会报错。

    FEL:条件即表达式,无需跳转

    FEL 的处理方式更像是函数式编程中的 match 或 when 表达式:

    AiProcessFlow<Tip, String> flow = AiFlows.<Tip>create()
        .prompt(Prompts.human("question: {query}"))
        .generate(model)
        .reduce(() -> "", (acc, chunk) -> acc += chunk.text())
        .conditions()
        .when(this::isNeedLog, this::log)
        .others(input -> input)
        .close();
    
    flow.converse().offer(Tip.from("query", "what's your name?"));
    

    关键在于 conditions 这个 DSL 关键字,它把分支逻辑封装成声明式语句,完全避免了“跳转”概念。分支动作也是函数式接口,易于测试和复用。整体语法延续了之前的流畅风格,无割裂感。

    你可以把它理解为:“在这个环节,根据某些规则做选择,然后继续往下走”,而不是“我要跳到哪个节点去”。

    三、设计哲学的碰撞:图 vs 流

    看到这里,你会发现 Eino 和 FEL 的差异远不止语法糖那么简单。它们代表了两种截然不同的设计哲学:

    维度 Eino ( Coze ) FEL
    抽象层级 图结构优先,强调可视化与拓扑控制 流程优先,强调语义表达与可读性
    学习成本 需要理解节点、边、分支、循环等图概念 只需掌握链式调用与函数组合
    类型检查 提供上下游类型对齐,部分类型在运行期执行 Compile 方法时类型检查 链式调用,天然的上下游类型推导和衔接,能够在编译时期识别类型错误,更安全

    四、思考:我们需要什么样的 AI 编排?

    对于开发者而言,AI 编排工具的终极理想无疑是:越简单越好用。我们渴望的是能快速落地、易于维护的解决方案,而不是陷入复杂的架构设计中。

    但现实往往需要权衡——简洁的 API 背后,是否牺牲了应对复杂场景的能力?强大的图模型,又是否会抬高使用门槛,让日常开发变得笨重?

    Eino 框架走了一条更偏“能力先行”的道路。它直接暴露图结构与节点控制,以原生支持循环、分支、动态跳转等复杂拓扑,为构建智能体、自动化流程等高级场景提供了坚实基础。

    而我们开发的 FEL 框架选择了“简洁至上”的路径,它能通过流畅的 Fluent API 抽象掉底层细节,让开发者专注业务逻辑本身,显著提升了常规任务的开发效率。

    我们认为,真正的成熟框架,不能只停留在“简单”或“强大”的单一体验上。我们还需要它在状态管理、流式处理、循环递归、错误恢复、可观测性等方面都交出令人信服的答卷。

    或许,在不同的业务条件之下,Eino 和 FEL 这样不同的写法,可以分别适合不同的场景吧?


    我们想多听听大家的意见,希望能够得到更多的反馈,让项目更好的向前演进。

    我们的项目地址是: https://github.com/ModelEngine-Group/fit-framework

    如果大家能够给我们提提意见,我们是非常开心的,会促使我们有更强的动力向前。

    如果过程中有一些问题,欢迎给我们 Github 的项目提 Issue 。

    如果有意愿或者喜欢,或者只是给我们鼓励一下,希望能给我们 github 项目点个小星星,真的感谢大家~

    6 条回复    2025-09-28 18:59:30 +08:00
    mightybruce
        1
    mightybruce  
       14 小时 56 分钟前
    我费了时间读了一遍,没解决任何 AI 实际问题, 请问你到底是自嗨还是解决了 AI agent 开发的任何痛点。
    CodeCaster
        2
    CodeCaster  
    OP
       11 小时 2 分钟前
    @mightybruce 对于开发 AI Agent ,使用现有的各个 AI 基础框架都可以,比如 LangChain 、Eino 、SpringAI ,我们的框架也提供了一套 Java 的 AI 原语,条条大路通罗马,我们并不是说开发 AI Agent 只能用我们的,用其他优秀的框架也可以的~ 只不过,我们想提供一种新的思路,因为当前的框架构建 AI 流程的过程基本都是通过图的,我们结合了响应式的写法,从写法上不一样,只是这样而已,提供代码示例供大家参考,来讨论一下而已。
    我觉得如果这样写简单,本身也是好的
    Isuxiz
        3
    Isuxiz  
       9 小时 31 分钟前
    肯定图结构更优,链或者流能表达的,图(一般实现是 DAG ,后面就用 DAG 代指了)都能表达。比如 DAG 中很容易表达多源多汇的概念。
    除了表达能力,DAG 的魔改能力比流强太多了。比如,很适合做分层设计,把 sub-graph 看成是一种 node 即可,再比如,很适合做可视化给非程序员展示概念,还比如,DAG 加一些手段就能比较好地支持分支和循环。这对于有野心的库来说基本全是优点。
    最后,我觉得,你这个是开发框架不是低/无代码平台,是面向程序员的,是要写代码的,那你为什么要担心图相关概念会给用户带来心智负担?这是学校里就学过的基本功啊,真忘了也没关系 ai 会很快再教会你的。
    CodeCaster
        4
    CodeCaster  
    OP
       7 小时 37 分钟前
    @Isuxiz 感谢你的回复!你说得对,DAG 理论上确实表达能力更强,但我想从另一个角度来看这个问题。

    响应式编程本身就是一种编程范式,FEL 使用响应式流来描述。就好比面向过程什么都能写,为什么要面向对象?面向对象并不是没有存在的必要,理论上图灵完备的语言都能实现相同功能,但不同范式解决的是开发效率和代码质量问题。

    从实际开发体验看:

    响应式流天然支持编译时类型检查,链式调用的上下游类型必须匹配才能编译通过。而 DAG 的节点连接往往依赖字符串 ID ,类型错误要到运行时才能发现。

    语法验证:

    ``` java
    // FEL - 编译时就知道类型不匹配
    flow.prompt(xxx).generate(model).reduce(stringReducer).someIntMethod() // 编译报错

    // DAG - 运行时才发现节点名写错了
    graph.AddEdge("node_model", "nod_log") // 拼写错误,运行时才报错
    ```

    然后是认知的问题,我觉得大家的确都学过图,但在日常业务开发中,我们更习惯"数据流转换"的思维模式。响应式编程让代码读起来更像业务描述:"接收请求 → 处理 prompt → 调用模型 → 处理结果"。我不知道大家怎么看?

    我们并不反对 DAG 设计,而是认为 **不同的工具应该匹配不同的场景**。就像 SpringMVC 的注解式编程和传统 servlet ,理论能力差不多,但开发体验天差地别。

    还是非常感谢你的讨论~
    Isuxiz
        5
    Isuxiz  
       6 小时 52 分钟前
    “DAG 的节点连接往往依赖字符串 ID ,类型错误要到运行时才能发现。”
    这个论据完全站不住脚,如果只是想要静态检查,别的语言完全可以做到,只是 go 是个表达能力被有意设计的很弱的语言天生残疾罢了。换 C++ Lisp 甚至 Typescript 都有各种方法实现静态检查,实在不行自己写个 DSL 也行啊。
    然后,我觉得如果你承认表达能力弱于图这个问题就没必要讨论哲学什么的了,胜负已分。
    定位就是**入门**工具,**轻量级**玩具,宣传应该走易用性的路线,和别人的框架打差异化竞争,这样的定位不需要到哲学层面讨论。
    CodeCaster
        6
    CodeCaster  
    OP
       6 小时 38 分钟前
    @Isuxiz #5 我觉得你可能言重了,这篇文章本意是选择了另一种编程范式进行实现,对比一下,并没有表达谁优谁劣,只是想说是不是不同场景用不同方式会更合适。所以我没有想引战,没有想分胜负。

    另外,当前的确是针对两个框架在比较,的确 Eino 是通过字符串来连接 ID 的,这个是事实而已。

    我觉得讨论没有问题,但是问题上升了可能需要适可而止了。求同存异。感谢支持~
    关于   ·   帮助文档   ·   自助推广系统   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1120 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 20ms · UTC 17:37 · PVG 01:37 · LAX 10:37 · JFK 13:37
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.