Agent 后端架构设计的 11 个真问题
引言:把 demo 变成生产服务,差的就是这 11 个问题
写一个 agent demo 是很容易的事。
OpenAI SDK 一把梭,加个 LangChain,加个工具列表,三十行 Python 跑起来就能聊。但如果你要把它做成一个对外提供服务的后端 —— 上 K8s、扛多租户、接前端、做计费、保 SLA —— 你就会一个接一个地撞上下面这 11 个问题:
- 多模型怎么接? 同一个服务里既要调 GPT-4o,又要调 Claude、Qwen、DeepSeek、Gemini,还要按业务场景路由。
- 多租户能不能热更模型? 客户 A 想换 model,不重启服务就能生效吗?
- 工具/知识/记忆怎么按 agent 分权? 不是所有 agent 都能调所有工具。
- agent 在系统里到底有几种状态? 不是简单的 running / done。
- 后端怎么扛多租户并发? 每个用户单独起进程显然不行。
- session 和 user 怎么绑? 安全和隔离的边界在哪。
- 一次工具调用从前端到 LLM 到工具再回来,链路是什么样?
- 长期记忆和短期记忆有什么区别?
- SSE 在前后端之间到底怎么走?
- 后端推流用什么数据格式?
- 工具调用对应的 SSE 事件有哪些字段?
这 11 个问题里,前 8 个是后端架构问题,后 3 个是 前后端协议问题。协议这块业界已经有一份相对成熟的标准 —— AG-UI Protocol(ag-ui.com),后面我会直接对着它讲,避免每家自创一套的混乱。
这篇文章的目标不是给你一份”标准答案”,而是把每个问题背后的设计空间、常见解法和它们的取舍讲清楚。读完你会知道:
- 这 11 个问题在工业实现里通常怎么解
- 每个解法的边界和代价是什么
- 为什么 AG-UI Protocol 的事件分类是那样的,背后什么逻辑
1. 多模型支持架构应该怎么设计?
需求很简单:一个服务里同时挂 OpenAI / Anthropic / Google / Qwen / DeepSeek / Bedrock / 自建 vLLM,业务方按场景指定用哪个。
但”挂多个模型”这件事本身有四个隐藏变量:
| 变量 | 说明 |
|---|---|
| Provider | OpenAI / Anthropic / Google / 火山 / Azure / Bedrock / Ollama / 自建 vLLM …… |
| Model | gpt-4o / claude-sonnet-4-6 / gemini-2.5-pro / qwen-max …… 同一 provider 下又有多个 |
| Purpose | react agent / planner / vlm judge / summarizer 可能要用不同模型 |
| Capability | 是否支持 function calling / 流式 / 图像输入 / thinking |
工业上最成熟的解法是 做一个独立的 AI API Gateway,把多模型适配做成基础设施。开源参考是 **new-api / one-api / LiteLLM**,国内大量公司私有化部署的就是这一套。
它的核心思想只有三句:
- 对外只暴露一套统一的 OpenAI 兼容协议,业务方不用学每家供应商的 API。
- 内部按 provider 拆 Channel Adaptor,每个供应商一个独立模块负责协议转换 / 流式 / usage / 错误。
- 顺手把鉴权、令牌、额度、计费、日志、健康检查、失败重试都打包,反正请求都过这一道。
整体架构长这样:

1 | ┌────────────────────────────────────────────────────────────────────┐ |
内部分层
new-api / one-api 的内部分层非常清楚,每一层的职责是固定的:
| 层 | 职责 |
|---|---|
| Router | 注册 API、Relay、Dashboard、Web 路由 |
| Middleware | 鉴权、限流、请求 ID、国际化、日志 |
| Controller | 解析参数、调度业务、统一响应 |
| Service | 额度、计费、渠道选择、用量记账 |
| Relay | 协议转换、流式处理、usage 提取、错误映射 —— 多模型差异收口在这里 |
| Channel Adaptor | 每个 provider 一个独立模块(OpenAI / Claude / Gemini / Bedrock / Azure / DeepSeek / Qwen ……) |
| Model | GORM 管理用户、令牌、渠道、日志、配置 |
主链路一句话:
1 | Client → Router → Middleware → Controller → Service → Relay → Upstream Provider |
这种分层最大的价值是 新增一个供应商,主要工作集中在新加一个 channel adaptor,业务链路、鉴权、计费、日志全都不用动。
Relay 层为什么单独拆出来
因为 AI 供应商的差异 几乎全部集中在协议层:
- 请求字段不同:OpenAI 用
messages,Gemini 用contents,Claude 把system单独拎出来。 - 鉴权方式不同:OpenAI / Anthropic 是 Bearer Key,AWS Bedrock 要 SigV4 签名,Azure 要带 endpoint 和 deployment ID。
- 流式格式不同:有的返回 SSE 配
data: [DONE],Anthropic 用event: message_stop,Bedrock 是事件 JSON 块。 - usage 字段不同:cache token / image token / audio token 的统计字段每家都不一样。
- 错误结构不同:HTTP 状态码 + 错误体字段名各家有自己的玩法。
把这些差异封装在 Relay 层,对上接收平台内部统一的请求 DTO + 上下文,对下调用具体 channel adaptor。Controller / Service 完全不用关心”现在请求的是 Claude 还是 OpenAI”。
一次模型调用的完整链路
1 | 1. 接收请求 客户端带 API Key 请求 /v1/chat/completions |
最关键的两个动作是 预扣 + 结算:预扣是为了防止用户额度不足还继续发起高成本请求;结算是为了按上游真实 usage 修正最终费用。
下面这些坑,新增任何一家 provider 都会撞
1. 路由维度选 model,不要选 provider。业务方只应该说”我要用 claude-sonnet-4-6“,不该说”打到 Anthropic”。同一个模型可能挂在多条渠道(Anthropic 官方 + Bedrock + 自建代理),网关来选。
2. 可选标量字段必须用指针类型。这是网关 / adapter 类项目的高频坑:
1 | // ❌ 错的 |
原因:用户显式传 0 / false 和 没传 是两种不同语义。temperature: 0 表示要确定性输出,stream: false 表示明确不要流式。用普通类型加 omitempty,重新 marshal 时这些零值会被吃掉,转发给上游就丢了。用指针才能区分 nil(没传)和 *v=0(显式传零)。
3. usage 字段必须按 provider 各自归一。OpenAI 把 cache hit token 算进 prompt tokens;Anthropic 单独返回 cache_read_input_tokens / cache_creation_input_tokens;Gemini 又是另一套结构。adapter 必须在自己内部把上游 usage 解到统一结构(input / output / cache_read / cache_create / image / audio_in / audio_out),否则要么重复计费要么漏计费。
4. 流式响应的”结束条件”非常脆。OpenAI SSE 用 data: [DONE],Anthropic 用 event: message_stop,Bedrock 是流式 JSON 块。adapter 必须各自做四件事:结束识别 + 最终 usage 提取 + 客户端断开感知 + 超时兜底,少一件都会导致日志不全或额度漏扣。
5. 错误必须分”可重试”和”不可重试”**。网络超时 / 临时 5xx / 限流 → 切渠道重试;鉴权失败 / 参数错误 / 余额不足 → 直接返回。把参数错误当可重试会浪费渠道配额,把临时 5xx 当不可重试会拉低成功率**。
6. JSON 序列化走统一 wrapper。new-api 里所有 JSON 操作都收口到 common.Marshal / common.Unmarshal,业务代码不直接 import encoding/json。这样以后切换更快的 JSON 库(sonic / jsoniter)或统一加错误埋点,只改一处。
7. Capability 检查必须前置。在网关路由阶段就判断”这个 model 支不支持 tools / vision / stream”,不要等到上游 400 才发现。
8. 模型映射要做在 channel 维度。同一个对外暴露的 gpt-4o 模型,可能在 channel #1 上叫 gpt-4o,在 channel #2(Azure 部署)上叫 my-gpt4-deployment,channel #3(自建代理)上叫 internal-gpt4。模型映射放在 channel 配置里,业务侧只看到统一的对外名。
工程取舍上一句话:不要从零写自己的 ChatModel 抽象层。直接用 new-api / one-api 这种成熟方案做二次开发,需要新增 provider 时按既有 channel adaptor 的模式加一个,工作量比从零写小一个数量级,而且鉴权 / 计费 / 健康检查 / 后台都是免费送的。
2. 多租户场景下模型切换能不能热更新?
能。客户 A 想把默认模型从 GPT-4o 切到 Claude,不需要重启服务、不需要发版,下一次请求就能生效。
但要先把”切换”四个不同的语义掰清楚,因为它们对应不同的处理路径:
| 触发方 | 切换对象 | 频率 | 典型场景 |
|---|---|---|---|
| 运营 / 管理员 | 默认模型、模型映射、模型价格 | 周级 | 上线了一个新模型、价格调整 |
| 业务方 / 租户 | 自己分组下的可用模型集合 | 月级 | 客户 A 想从 GPT-4 切到 Claude |
| 系统自动 | 某个具体渠道的可用性 | 秒级 | 上游 5xx 暴涨,自动切到备用渠道 |
| 请求级 | 某次请求实际打到哪个上游 | 毫秒级 | 路由按权重 / 优先级选 channel |
再加一种听上去 fancy 但 LLM 上下文里几乎没合理需求的 —— **”运行中的 turn 中途换 model”**:一个 turn 跑到一半换模型,前面的 thinking、tool call、token 全废。所以下面只讨论前四种。
承接上一节,我们直接用 new-api 这套 AI API Gateway 的方案 —— 它自带了热更新能力,不需要再额外造配置中心。
后台改 → DB → 缓存失效 → 下次请求生效
new-api / one-api 的热更新模式很直接:所有”会变的东西”都存在数据库里(渠道、模型映射、用户、令牌、分组、倍率、价格表达式),管理员在后台改完写入 DB,立刻刷新对应的内存缓存,下一次请求拿新值。

1 | ┌──────────────┐ ┌─────────────────────────────────────────────────────┐ |
new-api 启动流程里就明确做了这件事:初始化数据库 → 加载选项 / 模型倍率 / 渠道缓存 → 起后台任务(渠道检测 / 任务轮询 / 订阅额度重置)。运行期间,管理员每次保存配置都触发对应的 cache refresh,不需要重启进程。
“切换模型”在多租户下到底是切什么
这里有个常见的误区:**多租户切模型不是”每个租户一份独立配置”**。
new-api 这套方案的”多租户”是用户 / 令牌 / 分组三层,所有租户共用同一份 channel 池子,差异通过分组(group)映射出来:
| 层 | 控制对象 | 切换方式 |
|---|---|---|
| 用户层 | 用户状态、角色、余额、所属分组 | 后台改用户的 group |
| 令牌层 | API Key、过期时间、额度限制、可用模型范围 | 后台改令牌的 model 白名单 |
| 渠道层 | 不同分组可见不同渠道、不同分组用不同倍率 | 后台改 channel 的 groups 字段 |
所以”客户 A 想换模型”这件事的真实做法是:
- 在后台把客户 A 所属的分组改成”vip” / “claude-only” / 任何想要的标签。
- 渠道侧已配置好”
claude-sonnet-4-6这条 channel 对 vip 分组开放”。 - 客户 A 下次请求带
model: "claude-sonnet-4-6",路由算法看到 group 匹配 → 命中 → 走 Anthropic 的 channel adaptor。
整个过程客户感知不到任何重启,连 API Key 都不用换。
系统级自动切换:渠道路由 + 健康检查
第三种切换 —— “上游故障,自动切” —— 是 new-api 这类网关自带的能力。
核心是渠道路由策略,要解两个问题:选哪个渠道、失败后怎么办。
选择渠道时通常会看:
- 该渠道是否支持请求模型
- 用户分组是否允许使用该渠道
- 渠道是否启用(管理员可手动禁用)
- 渠道权重和优先级
- 最近健康检查是否通过
- 是否触发限流或错误熔断
失败处理:
- 可重试错误(网络超时 / 临时 5xx / 限流)→ 切换到备用渠道
- 不可重试错误(鉴权失败 / 参数错误 / 余额不足)→ 直接返回
- 记录失败原因供后台排查
- 后台周期性自动测试渠道,更新可用性
这样业务侧只关心模型名,不需要关心”OpenAI 上海区今天出问题了”。业务和具体供应商完全解耦,是这套方案最大的工程价值。
价格 / 倍率”快照”机制:热更下计费不出事故的关键
这是 new-api 的核心设计之一,单独拎出来讲。
热更的一个隐藏陷阱是:请求过程中如果价格被改了,预扣按 A 价、结算按 B 价,账永远对不上。
new-api 的解法是 请求开始时冻结快照:
1 | 1. 请求进来 → 估算 token → 查当时的价格 / 分组倍率 / 计费表达式 |
这样即使请求执行的 30 秒里管理员改了价格,已在跑的请求按旧价结算,新请求按新价 —— 行为可预期,账可对。
如果没有快照,热更必然带来计费事故。
几个反复踩过的坑
1. 缓存失效必须按 key 粒度。改了渠道 #5 的密钥,只该刷 #5 的缓存,不要全量重建。new-api 实现是 Cache.RefreshChannel(id) 而不是 Cache.Reload()。全量重建在大库下能让进程卡几秒,期间的请求要么慢要么失败。
2. 健康检查不能太勤。每分钟一次都算激进 —— 绝大多数模型 API 都按调用计费,健康检查本身要花钱。常见做法是 5~15 分钟一次主动 ping + 实时请求失败计数熔断两路结合。
3. “运行中切换”接口直接堵在入口。任何 API 都不要支持”切换正在跑的 turn 的 model”,包括看似无害的”切换 base_url”。一个 turn 选定的渠道必须从开始到结束保持不变。
4. 多数据库下的迁移要按 DB 类型分支。new-api 支持 SQLite / MySQL / PostgreSQL,每次给 channel / model 表加字段,迁移脚本必须分别测三套 —— SQLite 不支持很多 ALTER COLUMN,PostgreSQL 的 boolean / JSON 操作和 MySQL 差很多。
5. 敏感字段要脱敏展示。后台展示渠道密钥、支付回调密钥时,永远只显示前 4 位 + 星号,编辑时单独走”重置”按钮。一个截图泄露线上 OpenAI key 的事故就够你解释半年的。
6. 多租户≠多套配置。诚实地说:绝大多数 agent 后端用 new-api 这套方案做的”多租户”,本质是 数据隔离(user_id / project_id)+ 权限隔离 + 用量隔离,不是配置隔离 —— 全公司共用一份 channel 池子,租户之间的差异通过分组和倍率体现。如果你真的需要”每个租户一份独立配置 + 独立计费体系 + 独立后台” 的 SaaS 多租户,那是另一个量级的工程,需要在 new-api 之上再封一层 tenant 维度,把所有缓存按 tenant_id 分桶、限流计费都带租户维度。
回到开头那个问题 —— 客户 A 想换 model,不重启服务就能生效吗? 能。后台改两下分组 / 模型映射,下一次请求就走新模型。前提是你用的是 new-api 这套带管理后台 + 数据库 + 缓存刷新的方案,而不是把模型配置写在 YAML 里硬编码进进程的那种。
3. agent 的记忆 / 工具调用 / 知识解锁怎么实现?
这三件事看似无关,但在工业实现里它们共享同一个机制:按 agent 类型做能力门控。
一个真实的 agent 后端通常会有十几种 agent 类型(react / planner / executor / grader / summarizer / vlm-judge ……),每种 agent 能用的工具集、能读的知识库、能访问的记忆段都不一样。如果不做门控,会出三种问题:
- 能力越界:planner 不该自己执行操作,但你给它绑了
execute_command工具。 - 上下文污染:vlm-judge 不需要看历史 chat 记录,但你把整个 session memory 都喂给它,token 飙升。
- 权限漏洞:低权限的 agent 通过工具访问到了高权限的资源。
业界比较干净的解法是 元数据驱动的能力声明 + 准入检查:
1 | // 工具元数据 |
工具准入是这样,知识库 / 记忆段也是同一套思路 —— 都是”声明谁能看 + 运行时检查”的组合。
三个反复踩过的坑:
1. 默认拒绝 vs 默认允许。默认拒绝(必须明确声明 AllowedAgents 才能用)更安全但维护成本高,每加一个 agent 都要review 一遍工具列表;默认允许(空数组 = 全部可用)更方便但容易出权限事故。给写操作类工具用默认拒绝,给读操作工具用默认允许是常见折中。
2. 不要把权限检查放到工具内部。每个工具都自己写 if agentType != "executor" { return error } 是反模式 —— 一是重复,二是绕过点太多(万一忘写就是漏洞)。统一在 Registry 这一层做。
3. 知识库 / 记忆段的门控更要做行级。不是”这个 agent 能不能查这个知识库”那么粗,而是”这个 agent 在这个 session、这个 user、这个 project 下能查到哪些条目”。RAG 的检索 query 必须强制带上 user_id / project_id 过滤,不能信任 prompt 里写的”我是 user X”。
4. 一个 agent 在系统里会有哪些状态?
这是我见过被低估最严重的问题。
很多人写 agent 都用一个布尔变量 running bool 完事 —— 然后真正出 bug 的时候你完全不知道当前 agent 卡在哪。一个生产级的 agent 至少要分清楚 三层状态:
| 层 | 关注 | 例子 |
|---|---|---|
| Lifecycle(生命周期) | 进程级别的”还活着没” | idle / running / stopping / stopped |
| AgentStatus(业务状态) | 业务流程跑到哪 | new / planning / executing / reporting / complete / failed / canceled |
| StepStatus(单步状态) | 当前 step 在做什么 | pending / awaiting_user / executing / done / error |
这三层状态机互相独立但有约束关系:
1 | Lifecycle: idle ──▶ running ──▶ stopping ──▶ stopped |
为什么非要分三层?
因为它们的”可恢复性”不一样。Lifecycle 是进程级的,crash 就没了,必须靠外部重启;AgentStatus 是业务级的,可以 checkpoint 到 Redis,重启后能恢复;StepStatus 是 turn 级的,通常只在内存里,turn 一结束就清。把它们混成一个 enum,要么状态爆炸(笛卡尔积一下变 50+ 个状态),要么恢复语义一塌糊涂。
工程上有几个铁律:
1. 所有状态转换必须经过 CanTransitionTo() 校验。new → complete 直接跳是不允许的,canceled → running 也是不允许的。把校验集中在一个地方,不是散落在 20 个 if 里。
2. 每次转换都要发事件。状态从 planning → executing 要发一个 AgentStatusChanged 事件,前端可以渲染、监控可以打点、审计可以记录。**不要做”静默转换”**,那是事故的温床。
3. 终态是 sink。complete / failed / canceled / stopped 进去就出不来。任何”试图从终态再转出”的代码都要直接拒绝。
4. 中间态要有超时。planning 卡 30 分钟基本就是死循环了,必须有 watchdog 强制转 failed 并报警。
5. 后端 agent 是否支持多租户并发调用?
支持,但实现思路和”web 服务多租户”不太一样。
普通 web 服务的多租户是 stateless + 横向扩展:每个请求独立、共享的资源是数据库 / 缓存。但 agent 服务是 有状态的长连接 + 复杂调度 —— 一个 agent run 可能跑十几分钟、中间会有十几个 step、还要保持上下文。这意味着:
- 不能 per-request 起新 agent 实例(每次重新装载工具、记忆、状态太重)
- 也不能全局只有一个 agent 实例(多租户并发会互相污染)
业界常见的折中是 per-Run 实例化 + 注册表索引 + 跨节点路由:
1 | 请求进来 |
四个关键设计:
1. 实例的生命周期绑定 Run,不绑 User。一个用户可以同时有好几个 Run(多个对话窗口),每个 Run 一个独立 agent 实例,互不干扰。Run 结束 → agent 销毁。
2. 本地 registry + 远程 registry 双层。本地 sync.Map[runID]agent 是热路径,O(1) 查找;远程 Redis runner:location:{runID} → machine_ip 是兜底,用来跨节点找到 Run 在哪。
3. 跨节点用 HTTP 转发,不要用消息队列。Run 是有顺序的对话,HTTP 直接打到目标机器最简单可靠;用 MQ 会引入乱序、重试、幂等等一堆问题。
4. 资源维度的分布式锁是必需的。如果 agent 会调用对外部世界有副作用的工具(操作设备、改数据库、发消息),同一资源同一时刻只能有一个 agent 在操作 —— 加分布式锁,key 形如 dist_lock:{tenant}:{resource_id}。锁要带租约 + 自动续期 + defer 释放。
至于扩容:节点数 = ⌈活跃 Run 数 / 单机承载⌉,单机承载主要看模型并发上限和工具调用 IO 上限,不是 CPU。
6. session 和 user id 如何绑定?
这件事看起来很简单 —— session.user_id = current_user.id —— 但生产事故 80% 出在这里。
session 和 user 绑定要解的真正问题是:怎么在十几跳的链路里,让”这次操作属于谁”这件事不丢、不被篡改、不能被绕过。
绑定的链路至少有三道关:
1 | ┌──────────────┐ |
四个常被忽视的细节:
1. ctx 注入只能由网关来做,不能让业务层自己塞。如果某个 controller 写了 ctx.WithUserID("xxx"),整个权限模型就破了 —— 任何人能调到这个接口就能伪装。强制网关做、业务层只读不写。
2. Session 取出来必须校验 owner。SELECT * FROM sessions WHERE id = ? 是错的,必须是 WHERE id = ? AND user_id = ?。即使 session_id 在 URL 里被泄露了,也不能让别人加载到。
3. Run / Message / Event 全部冗余写一遍 user_id。看起来重复,但好处巨大:审计查”用户 X 在 5 月 9 号做了什么”是单表查,不用 join;删用户数据是单表删,不用递归找关联。
4. 多租户场景还要带 project_id / org_id。一个用户可能属于多个 project / org,权限不一样。绑定 key 实际上是 (user_id, project_id, org_id) 三元组,不是单 user_id。
7. agent 工具调用的完整流程怎么走?
这是最长也最容易掉链子的链路。从前端按下回车到工具执行完结果回灌给 LLM,一个完整的 turn 至少要经过这些环节:
1 | [1] Client POST /chat (SSE) {message, sessionId} |
这条链路里有几个只有踩过坑才知道的设计点:
1. Controller 一定要尽早返回。HTTP 层不能等 agent 跑完 —— agent 可能跑 30 分钟,HTTP 连接早断了。Controller 把命令丢到 MQ 或 worker pool 后立刻返回 SSE 头部,让 SSE 通道自己流后面的事件。
**2. agent 协程必须用 context.WithoutCancel**。如果 agent goroutine 直接继承 controller 的 ctx,HTTP 一断 ctx 就被 cancel,agent 立刻挂掉。要专门起一个独立 ctx,只在用户主动 stop 或全局 shutdown 时取消。
3. Tool call 的”两阶段”**。先发 ToolCallStart 占位** → 跑工具 → 再发 ToolCallEnd 带结果。中间可能有几秒到几十秒的工具执行时间,前端需要先看到”我开始调 X 工具了”再看到”我调完了”,否则用户以为卡死。
4. 工具结果必须回灌成 message 再喂给 LLM。OpenAI / Anthropic 的 function calling 协议都要求 tool 的 response 也是 message 流的一部分(role: tool / role: function),少了这一步 LLM 第二轮会乱。
5. 整个链路全程异步发 SSE 事件。不是等 agent 跑完一次性返回,是边跑边推 —— 这是 agent 体验和”普通 chatbot”的核心差别。
8. 长期记忆和短期记忆有什么区别?
短期记忆是 对话窗口里能直接看到的历史,长期记忆是 跨 session、跨 turn、被压缩 / 被检索出来的过去。两者解决的问题完全不同。
| 维度 | 短期记忆 | 长期记忆 |
|---|---|---|
| 存放 | 当前 session 的 message 数组 | 向量库 / KV 库 / 摘要库 |
| 检索 | 全量带(带不下就裁剪) | 按相关性检索 |
| 更新 | 每个 turn 自动 append | 异步生成 / 手动 / 定时摘要 |
| 作用 | 让 LLM 记住”你刚刚说了什么” | 让 LLM 记住”你上周说了什么” |
| 关键挑战 | 窗口溢出后怎么裁剪 | 检索准不准、噪声多不多 |
短期记忆的标准做法是 滑动窗口 + 摘要替换:
1 | 最新 N 个 turn 完整保留 ←—— 短期记忆主体 |
工程上常见的几个细节:
1. 滑动窗口的单位是 turn 不是 message。一个 turn 可能包含 user / assistant / tool 多条 message,按 message 截断会断在工具调用中间,下一轮 LLM 报”missing tool response”。要按 turn 整组裁剪。
2. 摘要要按 RunID 分组生成。一个 Run 内部的多步操作摘成一段,而不是把所有历史揉成一段。这样回放性更好。
3. 摘要不是一次性的,是增量的。每超过阈值就把”最旧的 K 个 Run”摘成一段,加入摘要列表;摘要列表本身也可能再被摘。多层级的”老三国”-> “演义” -> “故事梗概”。
4. 工具响应要补齐。如果摘要把某些 turn 删了,可能留下”有 ToolCallStart 没 ToolCallEnd”的孤儿,喂 LLM 会报错。要在摘要替换时强制补齐配对。
5. 长期记忆别一上来就上 vector DB。先看你的数据形态:结构化数据(用户偏好 / settings)走 KV 就行;半结构化(历史结论 / 决策)走全文搜索;只有真正语义模糊的才上 embedding + vector search。错配工具会让”长期记忆”变成”长期噪声”。
9. SSE 在前后端之间如何交互?
SSE(Server-Sent Events)是 W3C 定义的标准,本质是 HTTP 长连接 + 服务端单向推 text/event-stream。它不是 WebSocket,没有客户端反向推送能力 —— 但对 agent 流式输出场景刚好够用。
完整的请求/响应长这样:
1 | Client Server |
要让这条链路在生产稳定,下面这些不是”建议”,是必做:
1. 五个 HTTP 头部一个不能少。
Content-Type: text/event-stream浏览器才认 SSE 协议Cache-Control: no-cache防中间层缓存Connection: keep-alive保持长连X-Accel-Buffering: no关 Nginx / 反向代理的缓冲(这条是最容易漏的,漏了会导致事件全部 buffer 在网关侧,前端几分钟才看到一次推送)- 跨域场景的
Access-Control-Allow-Origin等 CORS 头部
2. 每条事件后必须 flush。Go 用 gin.Context.Writer.Flush(),Node 用 res.flush(),否则 GC 周期前数据卡在用户态 buffer 里。
3. 心跳不能少。SSE 的注释行(:heartbeat\n\n)不会触发 onmessage,但会保持 TCP 活跃。间隔 10~30 秒。没心跳的话,公司网络的中间盒子可能 60 秒就 RST 你的连接。
4. 客户端断线服务端必须立刻感知并停 agent。req.Context().Done() 监听 → 立刻发取消信号到 agent。不要继续生成事件喂到无人接收的 channel —— 不光浪费算力,还会泄漏 token 钱。
5. 服务端主动结束要发显式终止事件。比如 RunFinished 或 RunError,让前端知道是”正常结束”还是”出错了”,不要靠 TCP 断开来推断。
10. 后端推流通常采用什么数据格式?
SSE 的 wire format 是给定的(event: xxx\ndata: <string>\n\n),但 data 里到底放什么 —— 这一层每家都自己造过轮子,直到 AG-UI Protocol 出现把它收敛成行业事实标准。
AG-UI 推荐的格式是 JSON 包裹的 type-discriminated event:
1 | event: TEXT_MESSAGE_CONTENT |
每个 data 都是合法 JSON,对象第一个字段固定是 type,剩下是该 type 专属的 payload。
为什么这样设计?
| 设计选择 | 原因 |
|---|---|
| JSON 而不是 protobuf | 浏览器原生支持,DevTools 直接可读,调试零成本 |
| type 字段必填 | 客户端可以按 type 派发到不同 reducer,不用看 event 行就能 dispatch |
| payload 扁平不嵌套 | 减少前端取值层级(d.delta vs d.payload.content.delta) |
| 时间戳服务端打 | 按服务端时序排序,避免客户端时钟漂移导致渲染错乱 |
| delta 而不是 full text | 流式场景每次只推增量,减少带宽 |
工程实现上要注意:
1. 反序列化要按 type 路由到对应的 schema。后端发的时候是知道 type 和 payload 是配对的,前端拿到 data 后要先读 type,再用对应的 schema validate / 解析。不要用一个大 union schema 解所有事件,会把字段污染。
2. delta 一定要有 messageId。多消息并发流的场景,必须靠 messageId 把 delta 拼回到对应的 message —— 不能假设 delta 顺序就能还原。
3. 大 payload(图像 / 文件 / 长文本)走外部存储 + URL。SSE 一条 data 行实际上没有大小硬限制,但单条几 MB 会拖慢 flush,也会让浏览器 EventSource 反序列化卡顿。JSON 里只放 URL,资源走 CDN。
4. 二进制需求场景退到 WebSocket。如果你需要发音频流 / 帧数据这种二进制,硬塞进 SSE 用 base64 是糟糕的选择 —— 直接换 WebSocket。SSE 是为文本事件设计的。
11. agent 工具调用时 SSE 事件通常包含哪些字段?
这是个最具体的问题,正好可以全部对着 AG-UI Protocol 来讲。
AG-UI 把 agent 的事件分成 7 大类 30+ 个具体事件。规范的事件名是 PascalCase(RunStarted),但在 SSE 序列化时通常转成 SNAKE_CASE(RUN_STARTED),两种写法你在不同 SDK / 文档里都会见到。
| 大类 | 事件 | 说明 |
|---|---|---|
| Lifecycle | RunStarted / RunFinished / RunError / StepStarted / StepFinished | 控制 run 和 step 的生命周期边界 |
| Text Message | TextMessageStart / Content / End / Chunk | 文本流式输出的 start/delta/end 三段式 |
| Tool Call | ToolCallStart / Args / End / Result / Chunk | 工具调用的全流程事件 |
| State | StateSnapshot / StateDelta / MessagesSnapshot | 共享状态同步(RFC 6902 JSON Patch) |
| Activity | ActivitySnapshot / ActivityDelta | 结构化活动消息(PLAN / SEARCH 等) |
| Reasoning | ReasoningStart / End / MessageStart / Content / End / Chunk / EncryptedValue | 思考过程的流式(OpenAI o1 / Claude thinking) |
| Special | Raw / Custom / MetaEvent | 透传任意自定义事件 |
BaseEvent:所有事件都继承的三个字段
不管哪个具体事件,序列化出来都至少包含这三个字段:
| 字段 | 类型 | 说明 |
|---|---|---|
type |
string | 具体事件类型,例如 "RUN_STARTED" |
timestamp |
number? | 服务端打的毫秒时间戳,可选 |
rawEvent |
any? | 如果是从外部协议转换来的,原始数据可挂在这里,可选 |
后面具体事件的字段都是在 BaseEvent 之上加的。
Lifecycle 五件套:管 agent 跑完整轮次的边界
这是整个协议的骨架。**RunStarted 开场、中间一堆事件、RunFinished / RunError 收尾**。如果 agent 中间分成多个步骤(planner → executor → reporter 这种),用 StepStarted / StepFinished 标边界。
1 | // 1. 一次 run 开始 |
工程上几个反复踩过的点:
threadId和runId一定要分清。threadId跨多轮对话复用(约等于 session),runId是单次 agent 运行(一个 thread 通常会有多个 run)。前端要把同一个 thread 下的 run 拼成完整对话。parentRunId是树状结构的钥匙。子 agent 调用 / 重试 / 时间旅行回退都靠它建立 run 之间的父子关系。RunFinished的outcome是新版字段,老版本只有顶层result。实现端要兼容两种:旧 producer 不发outcome,新 consumer 不能假设它一定存在。StepStarted/StepFinished必须严格配对。前端的 step 进度条、loading 态全靠它,缺一个就错乱。
Text Message 四件套:流式文本输出
1 | // 1. 开始一条流式 message |
Tool Call 五件套:本节的核心
1 | // 1. 开始一次工具调用 |
State 三件套:共享状态同步
很多 agent 需要前后端共享一份”工作区状态”(当前打开的文件、已选中的元素、表单数据……)。AG-UI 用 快照 + 增量补丁 的方式同步:
1 | // 1. 全量快照:替换整个 state |
Activity 两件套:结构化活动消息
Activity 是比纯文本更结构化的”过程展示”,比如 planner 的计划、search agent 的搜索结果列表 —— 前端要按特定 UI 模板渲染。
1 | // 1. 整个 activity 的完整快照 |
Reasoning 七件套:思考过程的流式
针对 OpenAI o1 / Claude thinking / DeepSeek-R1 这种”推理模型”的内心独白,AG-UI 给了独立的事件通道。和 TextMessage 严格分开,前端通常折叠展示。
1 | // 1. 开始一段推理上下文 |
Special 三件套:透传 / 自定义 / 元事件
1 | // Raw —— 把外部系统的事件原样包一层 |
已弃用:THINKING_*
THINKING_START / THINKING_END / THINKING_TEXT_MESSAGE_START / CONTENT / END 这一组在 1.0.0 里会被移除,全部用 REASONING_* 替代。新代码不要用了;老代码迁移时按名字一一对应即可。
实现里最容易出问题的几个字段
1. toolCallId 是配对锚点。START → ARGS → END → RESULT 全部靠它配对。前端必须把它当 key 维护一个状态机,不能假设事件按顺序到达 —— 网络抖动 / 多 tool 并行都会乱序。
2. messageId / threadId / runId 三层 ID 别混。messageId 是单条消息(assistant 回复 / tool result);runId 是一次 agent 运行(可能包含多条 message);threadId 是会话(包含多个 run)。前端必须按这三层维度建索引,否则多 run 并发时会渲染串台。
3. parentMessageId 让 tool call 挂在正确的 message 上。一个 assistant message 可能并行发出多个 tool call,前端渲染需要”在这条 assistant message 下面挂这些 tool call”,靠 parentMessageId 串。
4. delta 的拼接要 idempotent。重连场景下可能同一个 delta 被重发,前端应该按 (messageId/toolCallId, sequence) 去重,不能简单 append。
5. 错误事件的”补偿性”(Paired Events Compensation)。这是 AG-UI 写在规范里的硬要求 —— 任何 *_START 事件必须有对应的 *_END 或显式失败收尾。如果 TOOL_CALL_START 发出去了但工具失败,必须发 TOOL_CALL_END + TOOL_CALL_RESULT(error),或者发一个 RUN_ERROR 把整个 run 结束掉。否则前端会一直 loading。
6. Reasoning 事件要单独通道渲染。REASONING_* 和 TEXT_MESSAGE_* 不要混。reasoning 是模型的”内心独白”,前端通常折叠展示;text message 是给用户的”正式回答”,要主区域展示。混了的话样式和折叠逻辑都会出问题。
7. RUN_FINISHED 的两种 outcome 都要处理。outcome.type === "success" 是正常结束;outcome.type === "interrupt" 是 agent 主动暂停等用户输入(典型场景:HITL 二次确认)。前端在 interrupt 下不该显示”已完成”,而要显示”等你确认”。
8. State / Activity 的 JSON Patch 必须严格按 RFC 6902。简化自创补丁格式 = 跨实现互不兼容。直接用现成的 JSON Patch 库(JS 用 fast-json-patch,Go 用 evanphx/json-patch)。
12. 结语:好的 agent 后端是做减法做出来的
回到开头那 11 个问题。
每一个单独看,都不是新东西。多模型、热更、状态机、SSE —— 这些技术原语在 web 后端、游戏后端、IoT 平台里都见过。但放在 agent 这个场景下,它们有一个共同的特性:复杂度会指数级累加。
多模型 × 多租户 × 多状态 × 多 tool × 多 agent 类型 × 多 session 并发 × 多 turn 长度 …… 任意两两组合都能新增一类边界 case。如果你不主动做减法,半年后会得到一个谁都不敢动的怪物。
我自己的经验,这 11 个问题之所以”真”,是因为它们对应了 11 处必须做出克制的设计选择:
| 问题 | 克制点 |
|---|---|
| 多模型 | 抽象只到 ChatModel,不要造一个万能 LLM 神器 |
| 热更新 | 只热更配置,不热更运行中的 turn |
| 能力门控 | 默认拒绝写工具,默认允许读工具 |
| 状态机 | 三层独立,不要笛卡尔积 |
| 多租户 | per-Run 实例化,不 per-User 也不 per-Request |
| Session 绑定 | 单一信任源 + 三道关 |
| 工具调用链路 | 控制器尽早返回,agent 用独立 ctx |
| 记忆 | 短期滑窗 + 长期摘要 / 检索,分清楚 |
| SSE | 五个头一个不能漏,flush + 心跳 + 断线感知 |
| 推流格式 | JSON + type 字段,对齐 AG-UI |
| Tool 事件 | toolCallId 串全程,paired events 必须补偿 |
每一项都是”我可以做更复杂的,但我选择不做”。这种克制不是偷懒,是对系统长期可维护性的负责。
agent 后端正在从”demo 阶段”快速进入”工业阶段”,AG-UI Protocol 这种标准化的出现就是信号 —— 大家终于愿意承认前后端协议没必要每家自创一套了。这是好事。下一波要标准化的应该是工具协议(MCP 已经在路上)和 agent 间通信协议(A2A 也在路上)。等这几个生态都成熟,今天讨论的很多”自家解法”会被规范替换掉。
但在那之前,把这 11 个问题想清楚,是把 agent 跑稳的最低成本。
参考资料
- AG-UI Protocol 官方文档 — 事件类型、payload schema、客户端实现
- Server-Sent Events 标准 — W3C 规范
- OpenAI Function Calling 规范 — tool call 协议
- Anthropic Tool Use 规范 — Claude 的工具调用语义
- Model Context Protocol (MCP) — 工具协议标准化
- 本系列 25 篇 · Agent CLI 事件循环与中断模型
- 本系列 11 篇 · Hermes Agent 记忆系统详解
- 本系列 15 篇 · Agent 记忆模块实践指南
