
27HTTP 与 SSE把 Eino 应用提供给前端使用《Eino 实战用 Go 构建 AI 应用与智能体》· 第 27 篇关键词HTTP API、SSE、事件协议、请求取消、超时前置条件已完成 17Runner 与事件流。前面的章节都在控制台里跑。这一篇把它变成服务前端发一个请求后端以流的方式把内容推回来。技术选型上SSEServer-Sent Events是最省事的选择——它天然符合“服务端单向持续推送”的场景浏览器原生支持不需要引入 WebSocket 的复杂度。但流式服务有几个容易踩的地方客户端断开后后端还在跑、超时没设、内部错误泄露。这一篇逐个处理。一、整体结构前端 │ 1. GET /chat?q... ↓ HTTP Handler │ 2. 建立请求级 context含超时 ↓ Runner.Query(ctx, q) │ 3. 拿到事件流 ↓ 逐事件转换 │ 4. delta / message / error / done ↓ SSE 推送第 2 步的context是关键它要绑定请求自身的生命周期这样客户端断开连接时会自动取消后端的 Agent 运行随之停止。二、事件协议设计不要把AgentEvent原样推给前端——那会把内部结构暴露出去前端也难以处理。先定义一层自己的协议事件含义数据delta流式内容分片{text: ...}message完整消息{text: ...}error出错{message: ...}done本次结束{}这层抽象带来两个好处前端只依赖这四种事件后端换实现不用改前端。可以控制暴露的信息量避免把内部错误细节推给用户。第 17 篇讨论过的事件类型映射工具调用、中断等都可以按需要加进来但协议一旦确定就应该稳定。三、几个必须处理的点1. 请求级超时与取消ctx,cancel:context.WithTimeout(r.Context(),60*time.Second)defercancel()r.Context()在客户端断开时会自动取消。这是让后端“及时停手”的关键——否则用户关了页面Agent 还在烧 Token。2. 错误不要泄露内部细节ifevent.Err!nil{writeSSE(w,flusher,error,{message:处理失败请稍后重试})return}不要把event.Err的原文推给前端。它可能包含内部路径、服务地址甚至配置信息。内部记录详细错误对外给通用提示并附上可追踪的请求 ID。3. 流式内容要正确消费ifmv.IsStreamingmv.MessageStream!nil{for{chunk,err:mv.MessageStream.Recv()iferrors.Is(err,io.EOF){break// 正常结束}iferr!nil{return// 客户端断开或超时静默结束}writeSSE(w,flusher,delta,...)}}io.EOF是正常终止。遇到其他错误通常意味着连接已经断了继续推送没有意义。4. 参数校验要前置query:strings.TrimSpace(r.URL.Query().Get(q))ifquery{http.Error(w,缺少查询参数 q,http.StatusBadRequest)return}在开始流式响应之前校验。一旦 SSE 头发出去了就无法再返回 400 之类的状态码了。四、完整示例下面这段程序用httptest起真实服务并实际发起请求因此输出可复现已实机运行packagemainimport(bufiocontextencoding/jsonerrorsfmtiolognet/httpnet/http/httpteststringstimegithub.com/cloudwego/eino/adkgithub.com/cloudwego/eino/components/modelgithub.com/cloudwego/eino/schema)// echoModel 简单回显用于演示服务化时的取消与错误处理。typeechoModelstruct{}func(m*echoModel)Generate(_context.Context,in[]*schema.Message,_...model.Option)(*schema.Message,error){returnschema.AssistantMessage(已收到您的请求in[len(in)-1].Content,nil),nil}func(m*echoModel)Stream(ctx context.Context,in[]*schema.Message,opts...model.Option)(*schema.StreamReader[*schema.Message],error){if_,err:m.Generate(ctx,in,opts...);err!nil{returnnil,err}sr,sw:schema.Pipe[*schema.Message](2)gofunc(){defersw.Close()for_,part:range[]string{已,收到您的请求}{select{case-ctx.Done():return// 上游取消时立即停止避免继续消耗。default:}sw.Send(schema.AssistantMessage(part,nil),nil)}}()returnsr,nil}func(m*echoModel)WithTools([]*schema.ToolInfo)(model.ToolCallingChatModel,error){returnm,nil}funcmessageOutput(event*adk.AgentEvent)*adk.MessageVariant{ifeventnil||event.Outputnil{returnnil}returnevent.Output.MessageOutput}// handleChat 以 SSE 形式把事件流推给前端。funchandleChat(runner*adk.Runner,w http.ResponseWriter,r*http.Request){// 1. 请求自身的超时控制客户端断开时 ctx 会被取消。ctx,cancel:context.WithTimeout(r.Context(),60*time.Second)defercancel()query:strings.TrimSpace(r.URL.Query().Get(q))ifquery{http.Error(w,缺少查询参数 q,http.StatusBadRequest)return}flusher,ok:w.(http.Flusher)if!ok{http.Error(w,当前服务器不支持流式响应,http.StatusInternalServerError)return}w.Header().Set(Content-Type,text/event-stream)w.Header().Set(Cache-Control,no-cache)iter:runner.Query(ctx,query)for{event,ok:iter.Next()if!ok{break}ifevent.Err!nil{// 2. 错误也要推给前端但不要泄露内部细节。writeSSE(w,flusher,error,{message:处理失败请稍后重试})return}mv:messageOutput(event)ifmvnil{continue}// 3. 流式内容需要自行消费ctx 取消时 Recv 会返回错误并退出。ifmv.IsStreamingmv.MessageStream!nil{for{chunk,err:mv.MessageStream.Recv()iferrors.Is(err,io.EOF){break}iferr!nil{return// 客户端断开或超时静默结束。}writeSSE(w,flusher,delta,mustJSON(map[string]string{text:chunk.Content}))}continue}ifmv.Message!nilmv.Message.Content!{writeSSE(w,flusher,message,mustJSON(map[string]string{text:mv.Message.Content}))}}writeSSE(w,flusher,done,{})}funcwriteSSE(w http.ResponseWriter,f http.Flusher,event,datastring){fmt.Fprintf(w,event: %s\ndata: %s\n\n,event,data)f.Flush()}funcmustJSON(v any)string{b,err:json.Marshal(v)iferr!nil{return{}}returnstring(b)}funcmain(){ctx:context.Background()agent,err:adk.NewChatModelAgent(ctx,adk.ChatModelAgentConfig{Name:assistant,Description:助手,Instruction:你是工单助手。,Model:echoModel{},})iferr!nil{log.Fatal(err)}runner:adk.NewRunner(ctx,adk.RunnerConfig{Agent:agent,EnableStreaming:true})srv:httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,r*http.Request){handleChat(runner,w,r)}))defersrv.Close()// 真实发起一次 SSE 请求并逐行解析。resp,err:http.Get(srv.URL/chat?q打印机离线)iferr!nil{log.Fatal(err)}deferresp.Body.Close()fmt.Println(HTTP 状态:,resp.StatusCode)fmt.Println(Content-Type:,resp.Header.Get(Content-Type))sc:bufio.NewScanner(resp.Body)forsc.Scan(){ifline:sc.Text();line!{fmt.Println( ,line)}}iferr:sc.Err();err!nil{log.Fatal(err)}// 反向用例缺少参数应返回 400。resp2,err:http.Get(srv.URL/chat)iferr!nil{log.Fatal(err)}deferresp2.Body.Close()fmt.Println(缺少参数时状态码:,resp2.StatusCode)}实机输出HTTP 状态: 200 Content-Type: text/event-stream event: delta data: {text:已} event: delta data: {text:收到您的请求} event: done data: {} 缺少参数时状态码: 400这份输出验证了几件事响应头正确状态 200、Content-Type: text/event-stream。流式分片逐条推送两个delta事件分开到达前端可以边收边渲染。有明确的结束信号done事件。参数校验生效缺少q时返回 400而不是进入流式流程。在生产环境里把httptest.NewServer换成标准的http.ListenAndServe即可log.Fatal(http.ListenAndServe(:8080,mux))五、生产环境的补充要求示例聚焦在协议和取消上真实服务还需要补齐这些要求说明身份认证每个请求都要识别用户并注入到context供工具鉴权用租户隔离会话历史、知识库范围按租户隔离限流按用户/租户限制请求频率与并发连接数控制长连接会占资源需要设上限请求追踪生成请求 ID贯穿日志与响应优雅关闭收到停止信号时等待进行中的请求结束超时分层请求超时、模型超时、工具超时分别设置心跳长时间无输出时发送注释行避免中间设备断开连接心跳怎么写SSE 协议允许发送以冒号开头的注释行作为心跳fmt.Fprint(w,: ping\n\n)flusher.Flush()在模型思考时间较长时比如 Agent 多轮调用心跳能避免连接被中间代理判定为空闲而断开。取消传播的完整链路客户端断开 → r.Context() 取消 → 传入的 ctx 取消 → 模型调用中断 → 工具调用中断 → 流读取返回错误循环退出这条链路能通靠的是每一层都传递了context。只要有某一层用了context.Background()取消就会在那里断掉——和第 10 篇回调里返回错误 context 是同一类问题。六、常见问题客户端断开后后端还在跑ctx没有绑定到请求。用r.Context()派生不要把context.Background()传进去。返回 400 没生效前端收到的是流校验放在了响应头设置之后。所有前置校验必须在写 SSE 头之前完成。流式推送到一半中断且无提示连接断开或超时。检查中间是否有代理设置了空闲超时考虑加心跳。前端拿到的中文乱码SSE 响应应使用 UTF-8。确认Content-Type可带charsetutf-8且不要对内容做额外转义。错误信息里有内部细节对外只给通用提示与请求 ID详细错误留在服务端日志中。长回答经常被截断检查各层超时设置网关、服务、模型。分层的超时容易叠加需要逐层确认。七、小结SSE 是 Eino 应用对外提供服务的最自然选择。把AgentEvent转成自己的事件协议delta/message/error/done前端更稳暴露更少。ctx必须绑定r.Context()客户端断开时后端才能及时停止。流式内容要自己消费io.EOF是正常结束其他错误通常意味着连接已断。前置校验必须在写 SSE 头之前完成否则无法返回错误状态码。对外错误要脱敏配合请求 ID 做追踪。生产环境还需补齐认证、限流、心跳、优雅关闭与分层超时。下一篇《Callbacks 与可观测性定位慢调用和失败节点》我们补上“看得见”的能力。参考资料与版本说明资料核对日期为2026 年 10 月 9 日Callback 用户手册Eino 官方示例HTTP SSE 服务Eino 主仓库本文示例按Eino v0.9.21编写使用回显模型与httptest不访问外部服务已实机运行正文输出为真实请求结果。