版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式,也不对任何收益结果作承诺。转载请注明出处。
第 1 章 Agent 服务不是「更重的后端接口」,而是「编排服务」
1.1 一次用户请求,为什么变成多次下游调用
传统后端接口的链路通常很短:一次请求对应一次数据库查询,或者一次下游 RPC 调用,耗时相对稳定。Agent 服务(Agent 指能自主决定「下一步调用什么」的程序)不一样:用户问一句,服务先把这句话交给大模型(下文简称「模型」);模型可能不直接作答,而是要求先调用某个工具(查资料、查数据库、调外部接口)。工具返回结果再回灌给模型,模型继续判断要不要再调。这样循环若干轮,才产出答复给用户。
所以前端看到的「一次请求」,在后端其实是若干次模型调用加上若干次工具调用的组合。并发、超时、失败处理要面对的对象,从「一次调用」变成了「一组调用」——这正是 Go 后端经验能派上用场的地方。
1.2 Go 老本行里,能直接搬过来的三样
goroutine 是 Go 的轻量并发单元,起一个的成本很低;context 负责在调用链上传取消信号和超时;连接池负责复用已经建好的 TCP 连接,省掉反复握手的开销。这三样在 Agent 服务里几乎原样可用:一次请求内并发跑多个工具调用、把总超时切成每段预算、复用到模型网关与工具服务的连接。
1.3 需要重新想的三件事
第一,耗时长。普通接口几百毫秒,Agent 一次请求可能是几十秒,超时预算和客户端等待策略都要重做。第二,有状态。多轮会话、流式输出的生命周期,都要求服务端记住「这次请求走到哪了」。第三,下游不可控。模型网关和工具服务可能限流、可能抖动,失败不再是异常,而是常态。
| 老本行的能力 | 在 Agent 服务里的对应物 | 迁移难度 |
|---|---|---|
| goroutine 起并发 | 一次请求内并发跑多个工具调用 | 可直接迁移 |
| context 传取消与超时 | 总预算切分段预算,客户端断开随之取消 | 可直接迁移 |
| 连接池与 Transport 复用 | 复用到模型网关、工具服务的连接 | 可直接迁移,但默认值要改 |
| 简单的错误返回 | 多路调用失败后的收敛与降级 | 需要重新设计 |
| 无状态接口 | 多轮会话状态与流式输出的生命周期 | 需要重新设计 |
第 2 章 context:把「总预算」切成「分段预算」
2.1 一条规则:父取消,子全跟着取消
Go 官方 context 包文档写明:Context 携带截止时间、取消信号和请求作用域的值,并且当一个 Context 被取消时,由它派生出的所有 Context 也会被取消。落到 Agent 服务上就是:只要请求链顶端的 Context 取消(用户关掉页面、网关超时),这次请求里所有还在跑的模型调用与工具调用都应该跟着停。
官方还给了两条硬规则:不要把 Context 存进结构体字段,而是作为函数的第一个参数显式传下去(惯用命名为 ctx);也不要传一个 nil 的 Context。Agent 服务的调用链比普通接口深,只要有一个人把 ctx 塞进结构体,取消信号就会断在半路。
2.2 写法:总预算里再切分段预算
⚠️代码待验证
// 第 1 层:整个请求的总预算,例如 8 秒totalCtx,cancelAll:=context.WithTimeout(r.Context(),8*time.Second)defercancelAll()// 官方要求:用完就调 cancel,否则会泄漏派生的子 Context// 第 2 层:单次工具调用再切一段更小的预算callCtx,cancelCall:=context.WithTimeout(totalCtx,2*time.Second)defercancelCall()res,err:=doToolCall(callCtx,req)iferr!=nil{iferrors.Is(err,context.DeadlineExceeded){// 这一段超预算:走降级分支,别让它拖垮总预算returnfallback(),nil}returnnil,err}_=resWithTimeout 的语义在官方文档里写得很直白:它等价于 WithDeadline(parent, now+timeout),也就是「比父更早到期的子 Context」。
2.3 判据:超时设得对不对,看什么
分段超时之和要留出小于总预算的余量;上线后盯「哪一个分段先超时」。如果每次都是总预算先到、分段从不触发,说明分段给得太宽,等于没设;如果分段频繁触发而总预算绰绰有余,说明分段给得太紧,重试成本被白白浪费。
第 3 章 并发编排多次工具调用
3.1 先判断依赖,再决定并发
能不能并发,取决于这些调用之间有没有数据依赖。互相不依赖的(例如同时查知识库、同时查订单、同时查天气)可以并发;后一个要用前一个的输出(例如先检索、再按检索结果查明细)只能串行。判断顺序永远是「先画依赖,再谈并发」。
| 场景特征 | 更适合并发 | 更适合串行 |
|---|---|---|
| 多个工具之间没有数据依赖 | 是 | 否 |
| 后一个工具要用前一个的输出 | 否 | 是 |
| 下游有严格调用配额 | 视配额定并发度 | 配额很小时用串行 |
| 要求「任一失败就整体回退」 | 是,配合立即取消 | 否 |
| 要求「部分失败也能出结果」 | 是,配合错误收集 | 否 |
3.2 并发度怎么定
并发度不是越大越好,它同时受三方约束:下游的调用配额、你的连接池上限、以及单机 goroutine 数量。取三者里偏小的那个作为上限。一个常见的做法是:先把并发度设成一个保守值,观察尾延迟和失败构成,再一格一格往上试。
3.3 失败怎么收敛
⚠️代码待验证
import"golang.org/x/sync/errgroup"funcrunTools(ctx context.Context,calls[]ToolCall)([]ToolResult,error){g,ctx:=errgroup.WithContext(ctx)// 任一子任务报错,组内 Context 会被取消g.SetLimit(maxParallel)// 并发度上限,按下游配额定results:=make([]ToolResult,len(calls))fori,c:=rangecalls{i,c:=i,c g.Go(func()error{callCtx,cancel:=context.WithTimeout(ctx,perCallTimeout)defercancel()res,err:=doToolCall(callCtx,c)iferr!=nil{returnfmt.Errorf("tool %s: %w",c.Name,err)// 收敛成统一错误}results[i]=resreturnnil})}iferr:=g.Wait();err!=nil{returnnil,err}returnresults,nil}errgroup 是 Go 官方工具库 golang.org/x/sync 里的一个包,可以理解为「会带回错误、能传播取消的 WaitGroup」。它的文档写明:WithContext 返回的 Context,会在第一个返回非 nil 错误的函数出现时被取消;SetLimit 则把组内活跃 goroutine 数限制在 n 以内。也就是说,「任一失败即整体取消」这件事,不需要你自己再写一遍。
第 4 章 该退化成串行,还是该加机器
4.1 先看三个指标
一是尾延迟:整体看 p95、p99,同时每个下游单独看(p95 指 95% 的请求快于这个耗时,用来观察「慢的那一小撮」)。
二是失败构成:把失败拆成超时、下游限流返回、客户端主动断开三类,占比不同,处理方向完全不同。
三是排队现象:连接池等待时间、goroutine 总数是否持续攀升。
4.2 加并发还是加机器
| 观察到的现象 | 结论 | 动作 |
|---|---|---|
| 并发度提高后,单次调用延迟几乎不变,总耗时下降 | 并发是有效杠杆 | 在上限内继续提高并发 |
| 并发度提高后,单次调用延迟明显上升、超时变多 | 下游已经接近打满 | 停止加并发,转为限流或扩容 |
| 连接池等待时间上升 | 连接不够用 | 调大每主机空闲连接数,或加机器 |
| goroutine 数持续上涨且不回落 | 存在泄漏或下游堆积 | 先查取消是否漏传,再决定扩容 |
| 客户端主动断开占比高 | 用户提前离开了 | 优先改善首 token 延迟,而不是加并发 |
4.3 一个容易被忽略的默认值
Go 官方 net/http 文档写明:Client 与 Transport 都是并发安全的,「为效率应当只创建一次并复用」,而不是每次请求现造一个。文档还告诉我们,DefaultMaxIdleConnsPerHost 这个常量的值就是 Transport 的 MaxIdleConnsPerHost 默认值——2。也就是说,默认情况下,到同一个主机的空闲连接只保留 2 条。Agent 服务同时要打模型网关和好几个工具服务时,这个默认值常常偏小,容易在连接池处排队。
文档里还有一条和它配套的规则:响应体必须被读完并关闭,否则底层那条 TCP 连接可能无法被后续请求复用。转发流式响应时这条尤其要注意——流没读到底就把响应体丢掉,连接基本就废了。
第 5 章 为什么要流式:用户感知的是首 token 延迟
5.1 非流式与流式,差别在哪
「首 token 延迟」指模型吐出第一个字所用的时间(token 是模型处理文本时的一个小单位,可以粗略理解为一个词或半个词)。非流式时,用户要等模型把整段话生成完才看到内容;流式时,第一个字一出来就能显示出来。
对 Agent 服务来说,这不只是体验问题:长回答的总耗时可能几十秒,用户会不会中途关掉页面,取决于他多久能看到「有东西在动」。
| 对比项 | 非流式(一次性返回) | 流式(SSE 增量返回) |
|---|---|---|
| 用户看到首字的时间 | 接近总耗时 | 接近首 token 延迟 |
| 客户端断开后的代价 | 整次生成白算 | 可及时取消,省掉后续算力 |
| 服务端转发复杂度 | 低 | 高:要处理 flush、背压与取消传播 |
| 适配场景 | 短回答、离线批量任务 | 面向人的实时问答 |
5.2 SSE 到底是什么
SSE 是 Server-Sent Events 的缩写,中文一般叫「服务端推送事件」,是一种基于 HTTP 的单向推送格式。WHATWG 的 HTML 规范是这一格式的定义来源,其「Server-sent events」一节截至 2026-10-07 仍是权威依据。规范里几条关键规定值得记住:事件流的 MIME 类型是 text/event-stream;以 data: 开头的字段值会被追加进数据缓冲区,遇到一个空行才派发一次事件;以英文冒号开头的行是注释、会被忽略(正好可以用来做保活心跳);event: 字段用来设置事件类型,默认类型是 message;retry: 字段用来设置重连时间;事件流一律按 UTF-8 解码。
后两条对写服务端的人很实用:想保活就发一行冒号注释;想区分「正文增量」和「结束信号」,就用 event: 字段加类型。
5.3 大模型接口的流式口径
截至 2026-10-07,主流大模型服务商都提供以 SSE 传增量的流式接口。OpenAI 官方指南把这条路径描述为基于 server-sent events 的 HTTP 流式,并说明默认行为是「先算完整段输出再一次性返回」,流式则允许调用方在模型继续生成的同时处理前面的内容(待验证:该官方页在写本文时无法直接抓取,以上表述来自检索到的官方页面摘要,未逐字复核)。Anthropic 的流式文档同样以 SSE 事件流组织增量输出(待验证:该官方页本次因访问限制无法读取)。
这里给一条纪律:各家的事件名与字段并不完全一致,接入时以你所用服务商当期的官方 reference 为准,不要把社区教程里的字段名当成官方契约。
大模型学习路线图:第 5 章讲的「一次请求打多次模型、流式吐字」,路线图把它对应到了服务端要补的能力项。放在资料包里,扫码即可获取:
第 6 章 Go 侧转发流式响应:四件必须处理的事
6.1 连接复用与 flush:两个容易被忽略的细节
前面提过的官方规则在这里第一次真正吃紧:流式请求是长连接,如果每个请求都新建 http.Client,连接会不断重建,握手与 TLS 开销叠加,机器还没跑满就先被连接数拖住。正确做法是全局一份 Client 与 Transport,按下游数量调节每主机空闲连接数。
Go 官方 net/http 文档还说明:Flusher 接口由「允许处理函数把缓冲数据推给客户端」的 ResponseWriter 实现;默认的 HTTP/1.x 与 HTTP/2 ResponseWriter 都支持它,但被包装过的 ResponseWriter 不一定支持,所以处理函数必须在运行时做类型断言。文档还提醒:如果客户端是通过 HTTP 代理连过来的,缓冲的数据可能一直等到响应结束才到达客户端——这句话解释了「本地看着是逐字出、线上却是整段蹦出来」的常见现象。
⚠️代码待验证
funcstreamHandler(w http.ResponseWriter,r*http.Request){flusher,ok:=w.(http.Flusher)// 运行时探测,不要假设一定支持if!ok{http.Error(w,"streaming unsupported",http.StatusInternalServerError)return}w.Header().Set("Content-Type","text/event-stream")w.Header().Set("Cache-Control","no-cache")w.WriteHeader(http.StatusOK)flusher.Flush()// 先把响应头推出去,别等第一个 tokenup,err:=upstream.Stream(r.Context(),req)// 用请求自带的 ctxiferr!=nil{return}deferup.Close()for{chunk,err:=up.Next()iferr!=nil{iferrors.Is(err,io.EOF){break}return// 上游出错或客户端已断开,直接收摊}if_,err:=w.Write(chunk);err!=nil{return// 写失败通常意味着客户端走了,别再往下跑}flusher.Flush()// 每个增量都推一次}}6.2 客户端断开后的取消传播
net/http 文档里有一条明确的迁移提示:旧代码用 CloseNotifier 检测断开,它已经废弃,「新代码应当改用 Request.Context」。也就是说,处理函数里要拿 r.Context() 去发下游请求,而不要用 context.Background()。客户端一断开,r.Context() 会被取消,下游的流式请求与工具调用随之取消——这直接对应官方那句「请求被取消或超时后,为它工作的所有 goroutine 都应尽快退出」。
6.3 背压与转发检查清单
上游产出速度可能快于客户端消费速度(客户端网络慢,或者上游一次吐一大段)。如果只顾着读、不顾客户端,内存里就会堆数据。给「未发送缓冲」设一个上限:超过就让上游读慢一点,而不是无限攒。下面的 Client 配置把超时交给 Context 管,并把每主机空闲连接数从默认值调大。
⚠️代码待验证
// 全局一份,别每次请求 new 一个varhttpClient=&http.Client{// 流式请求不设整体超时,由每段 Context 控制Transport:&http.Transport{MaxIdleConns:64,MaxIdleConnsPerHost:16,// 默认值是 2,流式并发下容易成为瓶颈IdleConnTimeout:90*time.Second,},}| 检查项 | 正确做法 | 常见错法 |
|---|---|---|
| 响应头 | 先设 Content-Type: text/event-stream,并立刻 flush 一次 | 等第一个 token 才写头 |
| 每次增量 | 写一次、flush 一次 | 攒够一批再 flush |
| 取消传播 | 下游请求用 r.Context() | 用 context.Background() |
| 连接复用 | Client 与 Transport 全局一份 | 每次请求新建 Client |
| 断开检测 | 写失败即停止循环 | 忽略写错误继续往下跑 |
| 空闲期 | 定期发一行冒号注释保活 | 长时间静默,被中间层断开 |
第 7 章 把经验落成可执行的清单
7.1 上线前要盯的指标
整体 p95 与 p99 延迟;流式场景下的首 token 延迟;单请求内的并发调用数与进程 goroutine 数;超时、下游限流、客户端断开三者的占比;连接池等待时间。指标口径要固定下来,否则前后两次排查的数据没法比。
7.2 出问题时的排查顺序
- 首字很晚才出来:先查是不是没 flush、上游是不是非流式,再查模型侧排队。
- 长回答中途卡住不动:先查中间代理是否缓冲,再查客户端是不是已经断开。
- 并发一加就大面积超时:先查下游限流配额,再查连接池上限。
- goroutine 数只涨不跌:先查取消有没有漏传(中途是不是有人把 ctx 换成了 Background),再查是否有 channel 阻塞。
- 偶发整段失败:先看分段超时是不是过紧,再看是否存在单个下游抖动。
7.3 什么时候该停手
一条判据就够了:当你继续提高并发度,尾延迟不再改善、失败占比反而上升时,就说明杠杆已经从「并发」转移到了「容量」。这时该加机器或加限流,而不是继续拧并发旋钮。反过来,如果并发度提高后单次调用延迟几乎不变、总耗时稳定下降,说明下游还有余量,可以继续试。
《LangChain + LangGraph + MCP 智能体开发实战》视频课:第 6、7 章讲的并发编排与流式转发,课程的服务端章节里有可对照的实现例子。放在资料包里,扫码即可获取:
附表 A:本文引用事实与出处对照表
| 序号 | 事实(英文为官方原文) | 出处 | 本文位置 |
|---|---|---|---|
| 1 | Context 携带截止时间、取消信号与请求作用域的值,跨 API 边界传递 | context 包文档 · Go 项目 · https://pkg.go.dev/context | 2.1 |
| 2 | “When a Context is canceled, all Contexts derived from it are also canceled.” | 同上 | 2.1 |
| 3 | “Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it.”(并规定 Context 应为第一个参数) | 同上 | 2.1 |
| 4 | “Failing to call the CancelFunc leaks the child and its children until the parent is canceled.” | 同上 | 2.2 |
| 5 | “WithTimeout returns WithDeadline(parent, time.Now().Add(timeout)).” | 同上 | 2.2 |
| 6 | 请求被取消或超时后,为它工作的所有 goroutine 都应尽快退出 | Go 博客《Go Concurrency Patterns: Context》· Go 项目 · https://go.dev/blog/context | 2.1 |
| 7 | “A Context does not have a Cancel method for the same reason the Done channel is receive-only” | 同上 | 2.2 |
| 8 | 入站请求关联的 Context 通常在处理函数返回时被取消 | 同上 | 6.3 |
| 9 | “Goroutines are not garbage collected; they must exit on their own.” | Go 博客《Pipelines and cancellation》· Go 项目 · https://go.dev/blog/pipelines | 4.1 |
| 10 | “Clients and Transports are safe for concurrent use by multiple goroutines and for efficiency should only be created once and re-used.” | net/http 包文档 · Go 项目 · https://pkg.go.dev/net/http | 4.3、6.1 |
| 11 | 常量 DefaultMaxIdleConnsPerHost 的值(2)即 Transport 的 MaxIdleConnsPerHost 默认值 | 同上 | 4.3 |
| 12 | 响应体未读完并关闭时,底层 TCP 连接可能无法被后续请求复用 | 同上 | 4.3 |
| 13 | Flusher 由可推送缓冲数据的 ResponseWriter 实现;默认 HTTP/1.x 与 HTTP/2 实现支持,但包装器不一定,需运行时断言 | 同上 | 6.2 |
| 14 | 客户端经 HTTP 代理连接时,缓冲数据可能直到响应结束才到达客户端 | 同上 | 6.2、7.2 |
| 15 | CloseNotifier 已废弃,新代码应改用 Request.Context | 同上 | 6.3 |
| 16 | NewResponseController 提供 Flush、SetWriteDeadline 等方法 | 同上 | 6.2 |
| 17 | WithContext 返回的 Context 在第一个非 nil 错误出现时被取消;SetLimit 限制组内活跃 goroutine 数 | errgroup 包文档 · Go 项目 · https://pkg.go.dev/golang.org/x/sync/errgroup | 3.3 |
| 18 | 事件流 MIME 类型为 text/event-stream;data 字段值追加进缓冲区、遇空行派发;冒号开头为注释;event 字段设类型(默认 message);retry 字段设重连时间;按 UTF-8 解码 | HTML 标准「Server-sent events」· WHATWG · https://html.spec.whatwg.org/multipage/server-sent-events.html | 5.2 |
| 19 | 待验证:OpenAI 官方流式指南将流式描述为基于 server-sent events 的 HTTP 流式,并说明默认先算完整段再返回 | OpenAI《Streaming API responses》· https://developers.openai.com/api/docs/guides/streaming-responses(本次直抓被拒绝,内容来自检索摘要,未逐字复核) | 5.3 |
| 20 | 待验证:Anthropic 流式文档以 SSE 事件流组织增量输出 | Anthropic《Streaming Messages》· https://docs.anthropic.com/en/docs/build-with-claude/streaming(本次因访问限制无法读取) | 5.3 |
附表 B:术语速查表
| 术语 | 一句话解释 | 在本文哪里用到 |
|---|---|---|
| goroutine | Go 的轻量并发单元,起停成本低 | 1.2、3.3、4.1 |
| context.Context | 在调用链上传递截止时间与取消信号的接口 | 2.1 |
| 首 token 延迟 | 从发请求到模型吐出第一个字所用的时间 | 5.1、7.1 |
| SSE | 服务端推送事件,一种基于 HTTP 的单向推送格式 | 5.2 |
| 背压 | 消费端跟不上时,反过来限制生产端的速度 | 6.4 |
| flush | 把缓冲区里的数据立刻推给客户端 | 6.2 |
| Transport | net/http 里真正负责建连接、复用连接的组件 | 4.3、6.4 |
| errgroup | 官方扩展库中带回错误与取消传播的 WaitGroup | 3.3 |
| 降级 | 某个下游失败时改走代价更小的备用路径 | 2.2、3.3 |
| p95 | 95% 的请求快于该耗时,用来观察慢请求 | 4.1 |
写在最后:这篇用到的资料
写这篇文章时,把相关的官方文档和源码又翻了一遍,顺手也整理了几份配套的东西:
- 大模型学习路线图:从零基础到能自己动手做 Agent,按阶段说明每一步该学什么、哪些可以先跳过
- 《LangChain + LangGraph + MCP 智能体开发实战》视频课:7 个模块,从私有化部署、Embedding+RAG 到 MCP+Agent 全流程
- AI 大模型知识库(在线可查):Agent Skills 从入门到落地、Claude Skills 完全指南等专题,按目录浏览即可
- 640 套 AI 大模型行业报告 + 经典 PDF 书籍:看行业落地案例和别人怎么做的时候用得上
- 大模型零基础到精通教学视频:跟着敲一遍,比只读文档快得多
资料是我自己整理的,放在下面这个码上,扫码即可获取:
添加时备注「AI」,优先通过。
资料按「先路线、再动手、最后查漏」的顺序整理好了,建议先看学习路线那一份,照着它挑一条适合自己当前基础的路径再往下看。