AI 大模型落地系列|Eino ADK体系篇:为什么一定要有 Agent 这层抽象
声明本文基于官方文档与本地源码校验编写重点参考 Eino ADK: Agent 抽象、Eino ADK: 概述、Eino ADK: Quickstart 以及本地github.com/cloudwego/einov0.8.5。AI 大模型落地系列Eino ADK 篇为什么 Agent 不只是一个 Prompt 包装器一文讲透 Agent 抽象与自定义 Agent 实战1. 为什么 Agent 抽象是必要的2. Agent 接口为什么这三个方法都不能少NameDescriptionRun3. AgentInput为什么输入是 Messages不是一个字符串Messages 是任务上下文不是单条 promptEnableStreaming 是建议不是强制4. AgentRunOption 和 AgentWithOptionsAgentRunOptionAgentWithOptions5. AsyncIterator为什么 Agent 不直接返回字符串Next() 为什么重要NewAsyncIteratorPair goroutine 为什么是常见写法6. AgentEvent / AgentOutput / AgentAction一次执行到底吐出了什么AgentName 和 RunPathAgentOutputAgentActionErr7. 自定义 Agent 实战从零实现一个 ConceptTutorAgent完整代码运行这段代码对应了哪些抽象进阶补充流式长什么样总结参考资料本篇只讲一点为什么 ADK 一定要单独定义Agent这层抽象很多人真正没看懂的不是Name、Description、Run而是这套协议到底统一了什么。本文只做四件事讲清Agent有什么用为什么它不是 Prompt 包装器讲透AgentInput / AgentRunOption / AsyncIterator / AgentEvent给一个零外部依赖的自定义 Agent demo帮你把后面Workflow / Runner / Interrupt的地基先打好1. 为什么Agent抽象是必要的如果没有Agent这一层AI 应用很容易长成一堆分散的模型调用这里直接调ChatModel那里自己拼MessagesTool 结果自己处理多 Agent 协作时每一层都重新定义输入输出中断、恢复、链路追踪、状态注入散在业务代码里但只要系统开始复杂一点问题就来了谁是这次执行单元的身份标识别的 Agent 怎么知道它能做什么调用方拿到的是最终字符串还是过程事件某个请求级参数该影响谁这次是输出了消息还是触发了跳转、中断、退出所以Agent抽象真正解决的不是“怎么调模型”。它解决的是怎么把一次智能体执行统一成一个可运行、可组合、可治理的对象。把这件事画开就是下面这张最小协议图你只要先记住一个判断就够了Agent不单独存在而是和AgentInput、AgentRunOption、AsyncIterator、AgentEvent一起构成运行协议。2.Agent接口为什么这三个方法都不能少官方定义很短typeAgentinterface{Name(ctx context.Context)stringDescription(ctx context.Context)stringRun(ctx context.Context,input*AgentInput,opts...AgentRunOption)*AsyncIterator[*AgentEvent]}NameName不只是“取个名字”。它至少承担三件事Agent 的身份标识执行链路里的节点名DesignateAgent(...)这类定向 option 的匹配目标DescriptionDescription也不只是注释。它更像对外公开的职责声明给人看知道这个 Agent 会什么给别的 Agent 看判断该不该把任务转给它RunRun才是核心。Run(ctx context.Context,input*AgentInput,opts...AgentRunOption)*AsyncIterator[*AgentEvent]这一个签名直接把四件事统一了一次 Agent 执行必须带context.Context输入统一走AgentInput请求级调参统一走AgentRunOption输出统一走事件流AsyncIterator[*AgentEvent]所以Run不是普通函数。它是在规定ADK 里的一次 Agent 执行应该以什么协议被启动、被调整、被消费。3.AgentInput为什么输入是Messages不是一个字符串官方定义typeAgentInputstruct{Messages[]Message EnableStreamingbool}typeMessage*schema.Message很多人第一次看到这里会下意识理解成“用户问题 一个流式开关”。这个理解太轻了。Messages是任务上下文不是单条 promptMessages里可以放的不只是用户这一句。它可以承载当前问题对话历史上游 Agent 结果背景知识样例数据系统约束也就是说Messages的意义不是“聊天格式”。它真正的价值是把一次任务所需的上下文统一收紧。如果输入只是一条string prompt那每个 Agent 都得自己决定历史怎么塞、系统约束怎么塞、Tool 结果怎么塞输入协议就会发散。EnableStreaming是建议不是强制这是一个特别容易踩的点。很多人会误以为EnableStreamingtrue就一定流式EnableStreamingfalse就一定非流式但官方文档强调得很清楚它只是一个建议。它只会影响那些“同时支持流和非流”的组件比如ChatModel。如果某个组件天然只支持一种输出方式比如很多 Tool它不会因为这个字段就突然变成流式。看图最直观这句最好直接背下来EnableStreaming控制的是偏好不是强制转换器。实际输出到底是不是流请看后面的MessageVariant.IsStreaming。4.AgentRunOption和AgentWithOptions这俩看起来像一回事其实不是这两个概念容易混。最简单的分法就一张表能力作用时机你可以先怎么理解AgentRunOption请求期这一次运行怎么调AgentWithOptions运行前这个 Agent 先被怎么包装AgentRunOption它是传给Run()的Run(ctx context.Context,input*AgentInput,opts...AgentRunOption)官方内置给了两个很典型的通用 optionWithSessionValues设置跨 Agent 读写数据WithSkipTransferMessages某些 Transfer 消息不进入 History除此之外ADK 还给了两个很实用的扩展点adk.WrapImplSpecificOptFn(...)adk.GetImplSpecificOptions(...)这套设计的价值很直接每个 Agent 都可以扩展出自己的请求级参数而不用把所有行为都塞进一套全局 option。比如后面 demo 里的WithAudience(newbie)WithAudience(interview)它就能证明AgentRunOption真的是“这次运行怎么调”而不是静态配置。DesignateAgent(...)则是更偏多 Agent 场景的能力opt:adk.WithSessionValues(map[string]any{}).DesignateAgent(agent_1,agent_2)它的真正作用就是在多 Agent 系统里只让指定名字的 Agent 看见这个 option。AgentWithOptions它是这样用的funcAgentWithOptions(ctx context.Context,agent Agent,opts...AgentOption)Agent官方当前内置支持的两个点是WithDisallowTransferToParentWithHistoryRewriter它们都不属于“这一次运行怎么调”。它们属于在真正执行前先把 Agent 包一层通用行为。所以别把这两个层级混掉。5.AsyncIterator为什么 Agent 不直接返回字符串官方定义typeAsyncIterator[T any]struct{...}func(ai*AsyncIterator[T])Next()(T,bool)ADK 这里的一个关键设计是Agent 不是“输入一个值输出一个值”的普通函数。一次 Agent 执行除了最终文本还可能产生中间输出Tool 消息跳转行为中断行为错误如果只返回string这些信息根本没地方放。所以 ADK 选择的是不直接给终值而是给一串按顺序消费的事件。Next()为什么重要Next()是阻塞式的。也就是每次调用时只会等两种结果等到一个新的AgentEvent或者等到迭代器关闭返回okfalse这意味着调用方的消费逻辑会非常稳定for{event,ok:iter.Next()if!ok{break}// handle event}NewAsyncIteratorPair goroutine 为什么是常见写法官方给了这套基础设施iter,gen:adk.NewAsyncIteratorPair[*adk.AgentEvent]()iter给调用方消费gen给 Agent 内部发事件自定义 Agent 常见实现会开 goroutine不是为了炫技而是因为Run()的目标不是等所有事做完再返回而是先把事件出口交出去然后内部异步地产生事件。如果不这么做你会把“事件流协议”重新写回“阻塞函数返回值”。6.AgentEvent/AgentOutput/AgentAction一次执行到底吐出了什么官方定义typeAgentEventstruct{AgentNamestringRunPath[]RunStep Output*AgentOutput Action*AgentAction Errerror}这部分只要抓住“事件里到底装了哪几类信息”就够了。AgentName和RunPathAgentName是谁发出的当前事件RunPath这个事件是沿着哪条调用链走到这里的在单 Agent 场景里你可能感受不强。但一到多 Agent 场景这两个字段就是链路上下文。AgentOutput官方定义typeAgentOutputstruct{MessageOutput*MessageVariant CustomizedOutput any}这说明 ADK 默认把“消息输出”当成第一公民同时也允许你挂自定义输出。而MessageVariant的价值是把流式和非流式统一起来typeMessageVariantstruct{IsStreamingboolMessage Message MessageStream MessageStream Role schema.RoleType ToolNamestring}最重要的不是字段多而是这几个判断位很实用IsStreaming当前到底是不是流Role当前是 Assistant 还是 ToolToolName如果是 Tool工具名是什么AgentAction很多人看AgentEvent时只盯着Output。但 ADK 还专门留了一条“行为输出通道”typeAgentActionstruct{ExitboolInterrupted*InterruptInfo TransferToAgent*TransferToAgentAction BreakLoop*BreakLoopAction CustomizedAction any}它的意义很直接Agent 不只会“说什么”还会“决定接下来怎么跑”。官方当前内置几类 ActionNewExitAction()立刻退出NewTransferToAgentAction(name)跳到目标 AgentInterrupted通知 Runner 当前中断BreakLoop让 LoopAgent 结束循环你可以先把它们理解成下面这种最小意图gen.Send(adk.AgentEvent{Action:adk.NewExitAction(),// 发送“退出”动作表示当前 Agent 结束执行不再继续后续流程})gen.Send(adk.AgentEvent{Action:adk.NewTransferToAgentAction(planner_agent),// 发送“转交”动作把当前任务切换给 planner_agent 继续处理})Err消费事件时Err绝对不能跳过ifevent.Err!nil{// handle error}否则很容易出现一种假象看起来“好像有输出”但实际执行已经坏了。7. 自定义 Agent 实战从零实现一个ConceptTutorAgent这段代码的目标不是做知识推理而是跑通 Agent 协议。先看执行链它想证明 4 件事自定义 Agent 本质上就是实现Agent接口Run()返回的是事件流不是字符串AgentRunOption可以做请求级调参不接模型 API也能把 Agent 协议本身跑通完整代码把下面代码保存成main.gopackagemainimport(contextfmtlogosstringsgithub.com/cloudwego/eino/adkgithub.com/cloudwego/eino/schema)// audienceOptions 是当前自定义 Agent 的实现级运行参数。// 这类参数不进入通用 Agent 接口而是通过 impl-specific option 透传。typeaudienceOptionsstruct{audiencestring}// WithAudience 为当前 Agent 注入“面向谁讲解”的运行选项。// 调用方可在不修改 Agent 接口的前提下按次覆盖执行行为。funcWithAudience(audiencestring)adk.AgentRunOption{returnadk.WrapImplSpecificOptFn(func(o*audienceOptions){o.audienceaudience})}// ConceptTutorAgent 是一个最小可运行的自定义 Agent。// 它不依赖大模型而是演示如何实现 Agent 接口、消费 AgentInput、产出 AgentEvent。typeConceptTutorAgentstruct{}// Name 返回 Agent 的稳定标识用于日志、协作和运行时识别。func(a*ConceptTutorAgent)Name(ctx context.Context)string{returnConceptTutorAgent}// Description 返回 Agent 的能力描述供人类或其他 Agent 判断是否适合处理某类任务。func(a*ConceptTutorAgent)Description(ctx context.Context)string{return负责把一个技术概念讲成新手能听懂的三段话}// Run 是 Agent 的执行入口。// 它从输入消息中提取任务内容读取实现级运行参数并通过事件流返回结果。func(a*ConceptTutorAgent)Run(ctx context.Context,input*adk.AgentInput,opts...adk.AgentRunOption)*adk.AsyncIterator[*adk.AgentEvent]{iter,gen:adk.NewAsyncIteratorPair[*adk.AgentEvent]()// Agent 的输出协议是事件流因此这里异步生成事件并通过 iterator 暴露给调用方。gofunc(){defergen.Close()// 优先响应上游取消或超时避免 goroutine 泄漏。iferr:ctx.Err();err!nil{gen.Send(adk.AgentEvent{Err:err})return}// 基础入参校验没有消息就无法构造任务上下文。ifinputnil||len(input.Messages)0{gen.Send(adk.AgentEvent{Err:fmt.Errorf(agent input messages is empty)})return}// 读取当前 Agent 自己定义的运行选项未传时使用默认值。cfg:adk.GetImplSpecificOptions(audienceOptions{audience:newbie},opts...)// 约定使用最后一条 user message 作为本次要讲解的概念。concept:lastUserMessage(input.Messages)ifstrings.TrimSpace(concept){gen.Send(adk.AgentEvent{Err:fmt.Errorf(last user message is empty)})return}reply:buildReply(concept,cfg.audience,input.EnableStreaming)// 将最终文本包装成标准 assistant 消息事件返回。gen.Send(adk.EventFromMessage(schema.AssistantMessage(reply,nil),nil,schema.Assistant,,))}()returniter}// lastUserMessage 从消息列表中逆序查找最后一条用户消息。// 这是一种常见约定最新的 user 输入通常代表当前任务指令。funclastUserMessage(messages[]adk.Message)string{fori:len(messages)-1;i0;i--{msg:messages[i]ifmsg!nilmsg.Roleschema.User{returnmsg.Content}}return}// buildReply 根据概念、受众和流式标记构造演示用回复。// 这里故意不接入真实模型目的是突出 Agent 输入/输出协议本身。funcbuildReply(concept,audiencestring,enableStreamingbool)string{prefix:面向新手ifaudienceinterview{prefix面向面试复盘}streamingHint:这次我没有实现流式输出所以会一次性返回完整结果。if!enableStreaming{streamingHint这次按非流式方式返回完整结果。}returnfmt.Sprintf(%s\n\n一句话定义这里把“%s”当成当前要讲解的概念。\n为什么重要这个 demo 不是在做真实知识推理而是在演示 Agent 如何围绕输入、事件和 option 组织一次执行。\n常见坑别把 Messages 理解成单条 prompt它其实承载的是任务上下文。\n补充%s,prefix,concept,streamingHint,)}funcmain(){ctx:context.Background()// 默认讲解概念支持命令行覆盖便于本地快速测试不同输入。concept:Agent 抽象iflen(os.Args)1{conceptstrings.Join(os.Args[1:], )}agent:ConceptTutorAgent{}// AgentInput 承载本次任务上下文而不只是单条 prompt。// 这里同时放入 system message 和 user message模拟一次最小对话输入。input:adk.AgentInput{Messages:[]adk.Message{schema.SystemMessage(你是一个负责解释技术概念的教学 Agent。),schema.UserMessage(concept),},EnableStreaming:true,}fmt.Printf(agent%s\n,agent.Name(ctx))fmt.Printf(description%s\n\n,agent.Description(ctx))// 直接运行自定义 Agent并通过实现级 option 注入受众信息。iter:agent.Run(ctx,input,WithAudience(newbie))for{event,ok:iter.Next()if!ok{break}// 事件级错误需要显式处理这也是事件流协议的一部分。ifevent.Err!nil{log.Fatalf(agent failed: %v,event.Err)}ifevent.Outputnil||event.Output.MessageOutputnil{continue}mv:event.Output.MessageOutputifmv.Messagenil{continue}fmt.Printf(assistant\n%s\n,mv.Message.Content)}}运行go mod init concept-tutor-demo go get github.com/cloudwego/einolatest go run.--AsyncIterator你会看到类似输出agentConceptTutorAgent description负责把一个技术概念讲成新手能听懂的三段话 assistant 面向新手 一句话定义这里把“AsyncIterator”当成当前要讲解的概念。 为什么重要这个 demo 不是在做真实知识推理而是在演示 Agent 如何围绕输入、事件和 option 组织一次执行。 常见坑别把 Messages 理解成单条 prompt它其实承载的是任务上下文。 补充这次我没有实现流式输出所以会一次性返回完整结果。这段代码对应了哪些抽象Name()给 Agent 身份Description()给 Agent 职责描述Run()按统一协议执行AgentInput.Messages承载任务上下文WithAudience(...)演示请求级 optionNewAsyncIteratorPair()建立生产者和消费者EventFromMessage(...)把输出装进AgentEventiter.Next()调用方按事件流消费进阶补充流式长什么样这次 demo 故意没实现流式就是为了说明EnableStreamingtrue不意味着你这个 Agent 必须流式输出。如果你只想看“流式MessageVariant怎么发”一个最小片段是stream:schema.StreamReaderFromArray([]adk.Message{schema.AssistantMessage(第一段。,nil),schema.AssistantMessage(第二段。,nil),})gen.Send(adk.EventFromMessage(nil,stream,schema.Assistant,))此时IsStreaming trueMessage nilMessageStream ! nil总结Agent不是一段配置而是一套统一输入、统一事件流、统一行为协议的运行对象。参考资料Eino ADK: Agent 抽象Eino ADK: 概述Eino ADK: QuickstartEino ADK: Agent 协作Eino ADK: Agent Runner 与扩展