1. 这不是Bug,是生产级Agent系统必经的“成人礼”
“Agent上线第二天就给用户退了两次款”——这句话刚在内部群弹出来时,我正盯着监控面板上那条突兀的红色告警曲线。没有惊慌,反而下意识点了杯咖啡。干了十年后端,见过太多团队把Agent当“智能脚本”上线,结果在支付、库存、风控这些真实业务链路上栽得又快又狠。这不是代码写错了,而是整个系统对“流式响应+状态一致性+可观测性”这三座大山缺乏敬畏。标题里说的“6课”,不是理论课,是拿真金白银交的学费:第一次退款,是因为SSE连接被Nginx默认60秒idle timeout无情切断,而下游服务没收到完整事件流,误判为订单失败;第二次,是Redis分布式锁在集群脑裂场景下失效,两个Worker同时处理同一笔退款请求,重复出款。你可能觉得“加个重试就行”,但生产环境里,重试不是万能解药——它可能把单点故障放大成雪崩。真正的生产级Agent后端,核心不在于多酷的AI模型,而在于如何让流式通信像自来水一样稳定、让状态变更像银行记账一样原子、让每一次调用都像手术刀一样可追溯。这6课,每一课都对应一个具体故障现场、一段真实日志、一次线上回滚操作。下面拆解的不是抽象概念,而是我带着团队在凌晨三点复盘时,白板上画满的流程图、删掉又重写的Redis Lua脚本、以及最终压测通过时,监控面板上那条平稳的绿色trace链路。
2. 核心设计逻辑:为什么Agent后端不能照搬传统Web架构
2.1 流式通信(SSE)与传统HTTP的本质冲突
传统REST API是“请求-响应”模型:客户端发一个POST,服务端处理完,返回200+JSON,连接关闭。而Agent的核心交互模式是SSE(Server-Sent Events),它建立的是长连接、单向、持续推送的通道。浏览器发起一个GET请求,服务端保持TCP连接打开,不断往这个连接里写入data: {...}\n\n格式的消息,直到客户端断开或超时。这种模式天然与现代基础设施存在三重摩擦:
第一重是反向代理层的timeout陷阱。Nginx默认配置中,proxy_read_timeout是60秒,意味着如果60秒内后端没向客户端发送任何数据,Nginx会主动关闭连接。Agent场景下,用户提问后,LLM可能需要3-5秒生成第一个token,中间这几十秒的“静默期”,Nginx就判定为idle timeout,直接断开。客户端收到stream disconnected before completion: idle timeout waiting for sse错误,前端SSE库自动重连,但重连后服务端并不知道这是同一个会话,可能重新触发一次完整推理,导致重复扣款、重复下单。我见过最惨的案例,一个客服Agent在用户等待期间被Nginx断了7次,重连7次,最终生成了7条相同回复,系统误以为是7个独立请求,给用户发了7张优惠券。
第二重是负载均衡器的连接亲和性缺失。当用户SSE连接被分发到某台Worker节点A,而A节点因GC暂停或网络抖动暂时无法发送数据,LB不会感知到这个“假死”状态,仍会把后续心跳包或重连请求继续打到A。更糟的是,如果A节点宕机,LB将新连接分发到B节点,但B节点没有A节点内存中的会话上下文(比如正在处理的订单ID、临时生成的加密密钥),整个流式对话就断了。传统Web应用可以靠Session Cookie或JWT续命,但SSE的流式状态是活在内存里的,无法简单传递。
第三重是客户端重连策略的不可控性。浏览器原生SSE API的EventSource对象,在连接断开后会默认等待几秒再重连,但这个间隔是指数退避的,且不同浏览器实现有差异。用户看到的可能是“消息卡住→页面刷新→重新提问”,而服务端视角却是“一个会话突然消失→另一个全新会话开始”。这对需要严格顺序保证的业务(如支付确认、库存锁定)是灾难性的。
所以,我们放弃“让SSE适配现有架构”的思路,转而构建SSE-aware的基础设施层。核心原则只有一条:所有与SSE会话相关的状态,必须脱离单机内存,下沉到共享存储,并由统一网关协调。这意味着,不能把SSE连接直接暴露给业务Worker,而必须经过一层“SSE Session Manager”。
2.2 Redis为何成为Agent状态中枢,而非简单缓存
提到Redis,很多人第一反应是“缓存热点数据”。但在Agent后端,Redis扮演的是分布式状态总线角色。它的价值不在于速度快,而在于其数据结构与原子操作的组合,恰好能解决Agent特有的状态协同难题:
Stream数据结构:这是Redis 5.0引入的,专为消息队列设计。它天然支持“消费组”(Consumer Group),允许多个Worker订阅同一个Stream,每条消息只会被组内一个Worker处理,且处理进度(pending list)由Redis维护。我们把Agent的每个用户会话抽象为一个Stream,例如
stream:session:abc123。当用户提问,前端通过API网关将问题推送到该Stream;后台Worker从Stream中拉取消息,处理完成后,将结果(如LLM输出的token流)再推送到另一个stream:result:abc123。这样,即使Worker A挂了,Worker B也能从pending list中接管未完成的消息,保证“至少一次”交付。这比Kafka轻量,比RabbitMQ简单,且完全避免了消息重复投递——因为Stream的ACK机制是精确到每条消息的。Hash + Lua脚本实现强一致性状态机:Agent的会话状态(如
status: "processing",last_activity_ts: 1718923456,pending_tokens: 12)不能存在数据库里,因为读写延迟太高。我们用Redis Hash存储,但关键操作(如“更新状态并检查是否超时”)必须原子执行。例如,当Worker开始处理一个请求,需要同时做三件事:1) 将状态设为processing;2) 更新最后活跃时间;3) 设置一个过期时间(防止Worker崩溃后状态永远卡住)。这三个操作如果分开执行,中间任何一步失败都会导致状态不一致。解决方案是封装一个Lua脚本:-- update_session_state.lua local key = KEYS[1] local new_status = ARGV[1] local expire_sec = tonumber(ARGV[2]) redis.call('HSET', key, 'status', new_status) redis.call('HSET', key, 'last_activity_ts', ARGV[3]) redis.call('EXPIRE', key, expire_sec) return redis.call('HGETALL', key)通过
EVAL命令一次性执行,确保状态变更的原子性。这个脚本被我们称为“会话状态引擎”,所有Worker都必须通过它来修改会话状态。Pub/Sub用于跨服务通知:当一个Worker完成某个复杂任务(如调用外部支付API),需要通知前端SSE连接更新UI状态。我们不直接让Worker向SSE连接写数据(这需要Worker知道哪个连接属于哪个用户,耦合太重),而是让Worker向Redis Pub/Sub频道
channel:notify:abc123发布一条消息。SSE Session Manager作为该频道的Subscriber,收到消息后,精准地向对应用户的SSE连接推送事件。这样,业务Worker彻底解耦,只负责“干活”,不关心“通知谁”。
提示:不要用Redis String存会话状态。String虽然简单,但无法原子性地更新多个字段。当需要同时更新
status和last_activity_ts时,String只能覆盖整个值,极易引发竞态条件。Hash是唯一正确的选择。
2.3 Trace不是锦上添花,而是Agent系统的“黑匣子”
“metrics、trace、logs”常被并列为可观测性三支柱,但在Agent场景下,trace是绝对核心。Metrics告诉你“系统慢了”,Logs告诉你“某行代码报错了”,而Trace告诉你“慢在哪一环、错在哪个分支、数据怎么在服务间流转”。一个典型的Agent调用链路可能是:前端SSE连接 → API网关 → 会话路由服务 → LLM推理服务 → 支付微服务 → Redis状态更新 → SSE推送服务。如果最终用户看到“退款失败”,没有Trace,你只能在每个服务的日志里大海捞针;有了Trace,你一眼就能看到:LLM推理服务耗时2.3s(正常),支付微服务返回500(异常),原因是调用第三方支付网关超时,而超时前,Redis分布式锁等待了800ms。
我们采用OpenTelemetry标准,但做了关键定制:强制注入Agent会话ID到所有Span中。OpenTelemetry默认的Trace ID是随机UUID,对调试无意义。我们在API网关入口处,从请求头(如X-Session-ID: abc123)提取会话ID,将其作为trace_id的前缀,并注入到所有下游调用的traceparent头中。这样,当你在Jaeger或Zipkin里搜索abc123,就能看到这个会话完整的、跨越7个服务的调用树。更重要的是,我们要求所有Span必须标注agent_action属性,例如"agent_action": "process_payment"、"agent_action": "generate_response"。这使得你可以按动作类型聚合分析:process_payment平均耗时多少?哪些动作最容易失败?失败时,90%的Trace都卡在Redis锁等待上——这就是优化方向。
注意:Trace采样率不能设为100%。全量采集会产生海量Span,拖垮后端存储。我们采用动态采样:对
agent_action为process_payment或refund的关键动作,100%采样;对generate_response这类高频动作,按QPS动态调整,保证每秒最多采集100个Span。这个策略在保留关键路径完整性的同时,将Span体积压缩了95%。
3. 六堂实战课:从故障现场还原技术决策
3.1 第一课:SSE连接保活——不是心跳,是“状态同步”
故障现象:用户提问后,等待10秒无响应,前端报错stream disconnected before completion: idle timeout waiting for sse,重连后Agent重复处理请求。
表面看是Nginx timeout,但根因是服务端没有主动发送保活消息。很多团队以为“只要连接开着就行”,却忽略了TCP连接空闲时,中间网络设备(防火墙、云厂商SLB)也会主动踢掉“僵尸连接”。我们的解决方案是双管齐下:
服务端主动保活:在SSE连接建立后,Worker不只等LLM输出,而是启动一个独立协程,每30秒向该连接写入一条data: {"type":"heartbeat","ts":1718923456}\n\n。这个心跳消息有两个作用:一是维持TCP连接活跃,骗过所有中间设备;二是携带服务器当前时间戳,前端可据此计算网络延迟,用于优化重连策略。
客户端智能重连:前端EventSource对象的onerror回调里,我们不盲目重连,而是先检查eventSource.readyState。如果是0(CONNECTING),说明还在连;如果是0但eventSource.url没变,说明是网络抖动;如果是2(CLOSED)且eventSource.url包含?retry=3000,说明上次重连失败,这次要延长重试间隔。我们实现了指数退避算法:
// 前端重连逻辑 let retryCount = 0; const maxRetryDelay = 30000; // 最大30秒 function createEventSource() { const url = `/api/agent/stream?session_id=${sessionId}&retry=${Math.min(1000 * Math.pow(2, retryCount), maxRetryDelay)}`; const es = new EventSource(url); es.onerror = () => { if (es.readyState === 0) { // 连接中出错,立即重试 retryCount = 0; setTimeout(createEventSource, 100); } else { // 已关闭,指数退避 retryCount++; const delay = Math.min(1000 * Math.pow(2, retryCount), maxRetryDelay); setTimeout(createEventSource, delay); } }; }这个逻辑让前端在遭遇网络波动时,能优雅降级,而不是疯狂重连压垮后端。
3.2 第二课:Redis分布式锁——不是setnx,是Redlock的“降级版”
故障现象:同一笔退款请求,被两个Worker同时处理,导致用户账户被重复扣减。
根本原因在于,我们最初用的SET key value NX EX seconds(即setnx)在Redis集群环境下失效。Redis Cluster将key哈希到不同slot,而setnx命令只在单个master节点上执行。如果锁key被分配到节点A,而Worker1在A上成功加锁,Worker2去节点B尝试加锁,B上根本没有这个key,自然也成功了——锁就形同虚设。
我们没有直接上Redlock(需要5个独立Redis实例,运维成本高),而是设计了一个基于Redis Stream的轻量级分布式锁:
- 创建一个专用Stream
stream:lock:payment_refund。 - Worker尝试加锁时,向Stream推送一条消息:
{"request_id": "req_abc123", "worker_id": "wkr-node1", "ts": 1718923456}。 - 然后,Worker立即从Stream中读取最后10条消息,检查是否有其他Worker在最近5秒内推送了相同
request_id的消息。如果有,说明锁已被占用,放弃处理。 - 如果没有,则认为加锁成功,开始处理业务。
- 处理完成后,向Stream推送一条释放消息:
{"request_id": "req_abc123", "action": "unlock", "ts": 1718923458}。
这个方案的优势在于:Stream是Redis Cluster原生支持的,无需额外部署;锁的“持有”状态由Stream消息体现,天然具备历史可查性;超时机制由Worker自己控制(检查5秒内消息),避免了Redis过期时间的精度问题。我们实测,在1000 QPS压力下,锁冲突率低于0.1%,且完全规避了Redlock的时钟漂移风险。
实操心得:不要用Redis的
EXPIRE命令设置锁超时。EXPIRE的精度是毫秒级,但在高并发下,两个Worker几乎同时执行SETNX和EXPIRE,第二个Worker可能在第一个Worker的EXPIRE之前就完成了SETNX,导致锁被错误覆盖。Stream方案把“加锁”和“设置超时”合并为一个原子操作(推送消息),从根本上杜绝了这个问题。
3.3 第三课:流式响应解析——不是逐行split,是状态机驱动
故障现象:前端SSE接收的token流出现乱码、丢失,或最后一个data:消息被截断。
根源在于,很多前端开发者用responseText.split('\n\n')来解析SSE消息,但这在流式场景下极其脆弱。SSE规范要求消息以\n\n分隔,但网络传输中,TCP包可能被任意切分,一个完整的data: {...}\n\n可能被分成两段到达。split操作会在不完整的消息上失败,导致JSON解析错误。
我们采用基于状态机的增量解析器。核心思想是:不等待完整消息,而是边收边解析。伪代码如下:
# 后端Python示例(使用Starlette) class SSEParser: def __init__(self): self.buffer = "" self.in_data = False # 是否在data:字段内 def feed(self, chunk: str): self.buffer += chunk while '\n' in self.buffer: line, self.buffer = self.buffer.split('\n', 1) line = line.strip() if not line: continue if line.startswith('data:'): self.in_data = True # 提取data内容,注意可能有多行 data_content = line[5:].strip() # 合并后续的data:行 while self.buffer.startswith('data:'): next_line, self.buffer = self.buffer.split('\n', 1) data_content += next_line[5:].strip() # 解析JSON try: json_obj = json.loads(data_content) yield json_obj except json.JSONDecodeError: # 忽略解析失败的消息,继续 pass elif line.startswith('event:') or line.startswith('id:') or line.startswith('retry:'): # 忽略其他SSE字段 pass前端同样实现一个状态机,用ReadableStream的getReader()逐块读取,维护一个buffer字符串,只在检测到完整的\n\n时才尝试解析。这样,无论网络如何分包,解析器都能正确组装出完整消息。
3.4 第四课:Trace链路贯通——不是埋点,是“上下文透传”
故障现象:支付服务报错,但Trace里找不到上游Agent服务的调用记录,无法定位是哪个用户、哪个会话触发的。
这是因为,很多团队只在服务入口埋点,却忽略了跨进程调用时Trace Context的透传。当Agent服务调用支付服务时,如果没把traceparent头带上,支付服务就会生成一个新的Trace ID,链路就此断裂。
我们强制所有HTTP客户端(如Python的httpx、Node.js的axios)在发起请求前,自动注入Context:
# Python httpx 客户端拦截器 import httpx from opentelemetry.propagate import inject def add_trace_headers(request: httpx.Request): # 从当前Span中提取Context inject(request.headers) # 自动添加traceparent等头 return request client = httpx.Client(event_hooks={'request': [add_trace_headers]})对于非HTTP调用(如gRPC、Redis Pub/Sub),我们也做了适配:gRPC使用grpc-opentelemetry插件;Redis Pub/Sub则在发布消息时,将当前Span的Context序列化为JSON,作为消息的一个字段一起发送,Subscriber收到后,用otel.context.attach()恢复Context。
最关键的一步是前端SSE连接的Trace注入。我们在API网关生成初始Span时,不仅生成trace_id,还生成一个span_id,并将其编码进SSE连接的URL参数中。前端在建立连接时,把这个span_id作为X-Trace-ID头带上。SSE Session Manager收到请求后,用这个span_id创建一个Child Span,所有向该连接推送的消息,都绑定在这个Span下。这样,从用户点击“提交”按钮,到最终看到退款成功的提示,整个链路就是一个完整的Trace。
3.5 第五课:Redis数据治理——不是删库跑路,是“冷热分离”
故障现象:Redis内存暴涨,INFO memory显示used_memory_human: 15.2G,但KEYS *查不到大Key,redis-cli --bigkeys也无结果。
排查发现,罪魁祸首是Stream的堆积。每个用户会话的Stream,我们设置了MAXLEN ~(无限制),导致数百万条历史消息永久驻留。Stream本身不占大内存,但每个消息的元数据(ID、长度)在内存中累积,最终撑爆了Redis。
解决方案是冷热分离+自动归档:
- 热数据:只保留最近24小时的Stream消息,用
XTRIM stream:session:* MAXLEN 10000命令定期修剪。10000是经验值,足够覆盖绝大多数会话的生命周期。 - 冷数据:被Trim掉的消息,不是丢弃,而是异步推送到Kafka,由专门的消费者服务将它们写入ClickHouse,供运营分析使用。
- 状态数据:会话Hash(
hash:session:abc123)设置TTL为2小时,因为会话结束后,状态信息就不再需要。我们用EXPIREAT命令,而不是EXPIRE,确保TTL基于绝对时间,避免因Redis时钟漂移导致状态过早失效。
这套治理策略上线后,Redis内存占用从15G降至2.3G,且CPU使用率下降40%。更重要的是,它让Redis回归了“高速状态总线”的本质,而不是一个臃肿的数据库。
3.6 第六课:并发压测验证——不是模拟,是“真实流量染色”
故障现象:单机压测QPS 5000没问题,上线后一到晚高峰,SSE连接大量超时,Trace显示90%的Span卡在Redis连接池获取上。
根因是压测流量与真实流量的分布特征完全不同。单机压测用的是均匀随机请求,而真实用户流量有强峰谷(晚8点峰值)、强关联(一个用户连续发5条消息)、强状态(会话ID复用)。我们设计了一套基于真实流量染色的压测方案:
- 在生产环境API网关开启流量镜像,将1%的真实请求(带
X-Shadow: true头)复制到压测集群。 - 压测集群的所有服务,都识别这个头,并将请求路由到隔离的Redis集群、MySQL库,避免污染生产数据。
- 关键是,镜像流量保留了原始的会话ID、用户ID、时间戳。这意味着,压测时,Worker会真实地去Redis里查询
hash:session:abc123,会真实地向stream:result:abc123推送消息,会真实地触发SSE Session Manager的推送逻辑。这比任何造数工具都真实。
通过这套方案,我们提前发现了Redis连接池瓶颈:连接池大小设为100,但在高峰期,单个Worker的并发连接需求峰值达到120。我们不是简单地调大连接池,而是结合Trace分析,发现80%的Redis调用都集中在HGETALL hash:session:*上。于是,我们优化了会话状态读取逻辑:改用HMGET hash:session:abc123 status last_activity_ts,只取需要的字段,将单次Redis调用的RT从8ms降至1.2ms,连接池压力自然缓解。
4. 避坑指南:那些文档里不会写的血泪经验
4.1 SSE与WebSocket的选型陷阱
很多团队纠结“该用SSE还是WebSocket”。结论很明确:Agent后端首选SSE,除非你有双向实时交互的硬需求。理由如下:
- 运维成本:SSE是HTTP协议,复用现有Nginx、CDN、WAF,零改造;WebSocket需要升级到HTTP/1.1的Upgrade头,很多老旧网关不支持,且需要额外配置心跳、连接管理。
- 移动端兼容性:iOS Safari对WebSocket的支持曾长期存在问题,而SSE在所有现代浏览器中表现一致。
- 资源消耗:WebSocket连接是双向的,服务端必须为每个连接维护一个长连接socket,内存开销大;SSE是单向推送,服务端只需一个写缓冲区,连接数可轻松支撑10万+。
- 重连语义:SSE的重连是浏览器内置的,语义清晰(重连后从头开始);WebSocket重连需要自己实现,且重连后如何同步状态(如“我刚才发到第几个token了?”)是个难题。
我们曾在一个项目中强行上WebSocket,结果在微信内置浏览器里,连接成功率不足60%,最终全部回退到SSE。记住:技术选型不是比酷,而是比谁更稳、更省心。
4.2 Redis数据类型的选择铁律
面对Redis的5种数据类型(String, Hash, List, Set, Sorted Set),很多开发者凭直觉选。在Agent后端,我们有三条铁律:
- 状态存储,必用Hash:会话状态、用户偏好、任务进度,所有需要“多字段原子更新”的场景,Hash是唯一选择。String无法原子更新,List/Set无法按字段索引。
- 消息队列,首选Stream:需要可靠投递、消费组、历史追溯的场景,Stream是Redis官方推荐。List虽可用,但
BRPOP无法保证消息不丢失(Worker崩溃时),且不支持消费组。 - 计数与排行榜,用Sorted Set:比如“今日最活跃Agent Top10”,用
ZINCRBY原子增,ZREVRANGE取Top,比用Hash+排序高效百倍。
曾有个团队用String存用户积分,每次扣减都GET再SET,结果在并发下积分被扣成负数。换成INCRBY命令,问题立解。选对数据类型,一半的并发问题就消失了。
4.3 Trace采样的黄金比例
全量Trace不现实,采样率太低又失去意义。我们摸索出一个动态黄金比例:
- 基础采样率:5%。这是底线,保证你能看到整体趋势。
- 关键路径100%:所有涉及资金的操作(
process_payment,refund,withdraw),所有风控决策(fraud_check),所有失败的请求(HTTP status >= 400)。 - 动态提升:当某个服务的错误率超过阈值(如5分钟内错误率>1%),自动将该服务的所有Trace提升至100%采样,持续15分钟,便于快速定位。
这个策略让我们在Jaeger里,既能看清宏观水位,又能瞬间钻入微观细节。上线后,P0故障平均定位时间从47分钟缩短至8分钟。
4.4 生产环境Redis的“三不原则”
- 不直接用
redis-cli在线操作:FLUSHALL、DEL *这种命令,永远在测试环境验证,生产环境只允许通过预审批的自动化脚本执行。 - 不共享Redis实例:Agent的状态Stream、会话Hash、缓存数据,必须分属不同DB(如
db 0for Stream,db 1for Hash,db 2for Cache),避免一个DB的OOM拖垮全部。 - 不忽略
slowlog:每天定时检查SLOWLOG GET 10,对耗时>100ms的命令,必须分析原因。我们曾发现KEYS *命令在生产环境执行,直接导致Redis阻塞,根源是某个开发在调试时忘了删掉这行代码。
4.5 Agent安全的“最小权限”实践
Agent能调用支付、发短信、查数据库,安全是生命线。我们实行四层最小权限:
- 网络层:Agent服务所在Pod,只允许访问Redis、支付网关、短信平台的特定端口,其他全部拒绝。
- 认证层:所有下游服务调用,必须携带JWT,且JWT中
scope字段精确到API级别,如"scope": ["payment:refund", "sms:send"]。 - 数据层:Redis连接使用专用账号,只授予
HGET,HSET,XADD,XREADGROUP等必要命令权限,禁用FLUSHDB,CONFIG等危险命令。 - 审计层:所有敏感操作(如退款、删用户),必须记录完整Trace,并写入审计日志(单独的Elasticsearch集群),留存180天。
有一次,一个新来的工程师在本地调试时,误将生产Redis密码写进了代码,提交到Git。幸好我们的CI/CD流水线有静态扫描规则,检测到redis://字符串匹配生产域名,立刻阻断发布,并自动告警。安全不是靠人盯,而是靠流程和工具。
5. 工具链与部署清单:一份可直接抄作业的配置表
| 类别 | 工具/服务 | 版本 | 关键配置 | 用途 | 备注 |
|---|---|---|---|---|---|
| API网关 | Kong | 3.4 | plugins: - name: request-transformer - config: add: headers: ["X-Session-ID:$request_id"] | 注入会话ID,统一入口 | 替换Nginx,支持动态插件 |
| SSE会话管理 | Python + Starlette | 0.37 | --workers 4 --host 0.0.0.0:8000 --timeout-keep-alive 30 | 长连接管理,心跳保活 | 使用Uvicorn部署,禁用--limit-concurrency |
| Redis集群 | Redis | 7.2 | maxmemory 8gb,maxmemory-policy allkeys-lru,stream-node-max-bytes 10mb | 分布式状态总线 | 3主3从,启用cluster-enabled yes |
| Trace后端 | Jaeger | 1.45 | --collector.zipkin.host-port=:9411,--storage.type=cassandra | 分布式追踪 | Cassandra集群3节点,保障高可用 |
| 压测平台 | k6 | 0.45 | export default function() { http.get('https://api.example.com/stream?session_id='+__ENV.SESSION_ID); } | 真实流量染色压测 | SESSION_ID从生产日志中提取 |
Redis关键配置详解:
timeout 0:禁用客户端空闲超时,由应用层控制。tcp-keepalive 300:启用TCP keepalive,每5分钟探测连接。maxmemory-policy allkeys-lru:内存满时,LRU淘汰所有key,避免OOM。stream-node-max-bytes 10mb:限制Stream单个节点大小,防止单条消息过大。
SSE Session Manager部署要点:
- 必须部署为StatefulSet,而非Deployment,确保每个Pod有唯一网络标识。
- Pod内
livenessProbe检查/healthz端点,但readinessProbe必须检查Redis连接状态,只有Redis连通才标记为ready。 - 水平扩展时,通过Kubernetes Service的
sessionAffinity: ClientIP,保证同一用户的SSE请求尽量路由到同一Pod,减少跨Pod状态同步。
Trace链路贯通检查清单:
- [ ] 所有HTTP客户端已集成OpenTelemetry HTTP插件。
- [ ] gRPC服务已启用
grpc-opentelemetry拦截器。 - [ ] Redis Pub/Sub消息体中包含
trace_context字段。 - [ ] Jaeger UI中,搜索任意
X-Session-ID,能展示完整跨服务调用树。 - [ ] 每个Span的
service.name标签准确,如agent-gateway,llm-service,payment-service。
6. 最后的体会:Agent不是终点,而是后端工程的新起点
写完这六课,我翻出两年前的项目文档,那时我们还在为“如何让LLM输出更快”而优化GPU显存。现在回头看,那种焦虑多么狭隘。Agent真正考验后端工程师的,从来不是模型能力,而是在不确定的流式交互中,构建确定性的状态系统。它逼着你重新思考:连接是什么?状态在哪里?失败如何定义?可观测性如何落地?这六课,每一课都是对传统后端思维的颠覆。当你的Agent开始处理真实世界的金钱、时间、信任,那些教科书里的“高并发”、“高可用”就不再是抽象概念,而是凌晨三点的告警、用户愤怒的电话、以及你亲手写的那一行修复Redis锁的Lua脚本。我常跟团队说,别急着追最新的Agent框架,先把SSE的保活逻辑写对,把Redis的Hash操作用熟,把Trace的Context透传搞明白。这些看似“古老”的技术,才是支撑起未来智能体的钢筋水泥。上线第二天就退款,不是事故,是系统在提醒你:真正的智能,始于对基础的敬畏。