← 返回写作
25 分钟阅读

Agent 后端架构设计的 11 个真问题

Agent 后端架构设计的 11 个真问题

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 Protocolag-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**,国内大量公司私有化部署的就是这一套。

它的核心思想只有三句:

  1. 对外只暴露一套统一的 OpenAI 兼容协议,业务方不用学每家供应商的 API。
  2. 内部按 provider 拆 Channel Adaptor,每个供应商一个独立模块负责协议转换 / 流式 / usage / 错误。
  3. 顺手把鉴权、令牌、额度、计费、日志、健康检查、失败重试都打包,反正请求都过这一道。

整体架构长这样:

AI API Gateway 架构:业务统一接 OpenAI 兼容协议 → Router/Middleware/Controller/Service/Relay/Channel Adaptor 分层 → 各 provider 独立 adaptor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌────────────────────────────────────────────────────────────────────┐
│ 业务系统 / Agent 进程 只接一个 OpenAI 兼容入口 │
│ POST /v1/chat/completions { "model": "claude-sonnet-4-6" } │
└──────────────────────────────────┬─────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│ AI API Gateway (e.g. new-api / one-api / LiteLLM) │
│ ───────────────── │
│ Router → Middleware → Controller → Service → Relay │
│ │ │
│ ┌───────────────────────┼──────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌──────┐│
│ │ OpenAI Adapt│ │ Claude Adapt│ │ AWS ││
│ │ 请求转换 │ │ 请求转换 │ │Bedrock││
│ │ 流式转换 │ │ 流式转换 │ │ 签名 ││
│ │ usage 提取 │ │ usage 提取 │ │ ... ││
│ │ 错误归一 │ │ 错误归一 │ │ ││
│ └──────┬──────┘ └──────┬──────┘ └──┬───┘│
└────────────────────────┼──────────────────────┼────────────┼────┘
▼ ▼ ▼
OpenAI 真接口 Anthropic 真接口 Bedrock

内部分层

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
2
3
4
5
Client → Router → Middleware → Controller → Service → Relay → Upstream Provider

Model

Database

这种分层最大的价值是 新增一个供应商,主要工作集中在新加一个 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
2
3
4
5
6
1. 接收请求       客户端带 API Key 请求 /v1/chat/completions
2. 鉴权校验 检查令牌、用户状态、模型权限、额度、限流
3. 渠道选择 按模型 + 分组 + 权重 + 健康度选上游
4. 请求转换 Relay 把统一请求转成目标供应商格式
5. 响应处理 支持普通响应和 SSE 流式,统一错误结构
6. 计费落库 按真实 usage 结算额度,写请求日志和费用

最关键的两个动作是 预扣 + 结算:预扣是为了防止用户额度不足还继续发起高成本请求;结算是为了按上游真实 usage 修正最终费用。

下面这些坑,新增任何一家 provider 都会撞

1. 路由维度选 model,不要选 provider。业务方只应该说”我要用 claude-sonnet-4-6“,不该说”打到 Anthropic”。同一个模型可能挂在多条渠道(Anthropic 官方 + Bedrock + 自建代理),网关来选。

2. 可选标量字段必须用指针类型。这是网关 / adapter 类项目的高频坑:

1
2
3
4
5
6
7
// ❌ 错的
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`

// ✅ 对的
Temperature *float64 `json:"temperature,omitempty"`
Stream *bool `json:"stream,omitempty"`

原因:用户显式传 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,立刻刷新对应的内存缓存,下一次请求拿新值。

new-api 风格热更链路:后台改 → DB 落库 → Cache 按 key 失效 / 重新加载 → 下次请求生效,全程不发版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌──────────────┐  ┌─────────────────────────────────────────────────────┐
│ 管理后台 UI │ │ AI API Gateway │
│ ────────────│ │ ───────────────────── │
│ 渠道管理 │──▶ Controller │
│ 模型映射 │ │ │ │
│ 分组倍率 │ │ ▼ │
│ 价格表达式 │ │ Service.UpdateChannel ─── tx ──▶ DB │
│ 权重调整 │ │ │ (channels / models) │
│ 用户分组 │ │ ▼ │
└──────────────┘ │ Cache.Refresh(channelID) ◀──── 后台周期 health check│
│ │ │
│ ▼ │
│ 下一次 /v1/chat/completions 请求 → 拿到新值 │
└─────────────────────────────────────────────────────┘

new-api 启动流程里就明确做了这件事:初始化数据库 → 加载选项 / 模型倍率 / 渠道缓存 → 起后台任务(渠道检测 / 任务轮询 / 订阅额度重置)。运行期间,管理员每次保存配置都触发对应的 cache refresh,不需要重启进程。

“切换模型”在多租户下到底是切什么

这里有个常见的误区:**多租户切模型不是”每个租户一份独立配置”**。

new-api 这套方案的”多租户”是用户 / 令牌 / 分组三层,所有租户共用同一份 channel 池子,差异通过分组(group)映射出来:

控制对象 切换方式
用户层 用户状态、角色、余额、所属分组 后台改用户的 group
令牌层 API Key、过期时间、额度限制、可用模型范围 后台改令牌的 model 白名单
渠道层 不同分组可见不同渠道、不同分组用不同倍率 后台改 channel 的 groups 字段

所以”客户 A 想换模型”这件事的真实做法是:

  1. 在后台把客户 A 所属的分组改成”vip” / “claude-only” / 任何想要的标签。
  2. 渠道侧已配置好”claude-sonnet-4-6 这条 channel 对 vip 分组开放”。
  3. 客户 A 下次请求带 model: "claude-sonnet-4-6",路由算法看到 group 匹配 → 命中 → 走 Anthropic 的 channel adaptor。

整个过程客户感知不到任何重启,连 API Key 都不用换。

系统级自动切换:渠道路由 + 健康检查

第三种切换 —— “上游故障,自动切” —— 是 new-api 这类网关自带的能力。

核心是渠道路由策略,要解两个问题:选哪个渠道、失败后怎么办。

选择渠道时通常会看:

  • 该渠道是否支持请求模型
  • 用户分组是否允许使用该渠道
  • 渠道是否启用(管理员可手动禁用)
  • 渠道权重和优先级
  • 最近健康检查是否通过
  • 是否触发限流或错误熔断

失败处理:

  • 可重试错误(网络超时 / 临时 5xx / 限流)→ 切换到备用渠道
  • 不可重试错误(鉴权失败 / 参数错误 / 余额不足)→ 直接返回
  • 记录失败原因供后台排查
  • 后台周期性自动测试渠道,更新可用性

这样业务侧只关心模型名,不需要关心”OpenAI 上海区今天出问题了”。业务和具体供应商完全解耦,是这套方案最大的工程价值。

价格 / 倍率”快照”机制:热更下计费不出事故的关键

这是 new-api 的核心设计之一,单独拎出来讲。

热更的一个隐藏陷阱是:请求过程中如果价格被改了,预扣按 A 价、结算按 B 价,账永远对不上

new-api 的解法是 请求开始时冻结快照

1
2
3
4
5
1. 请求进来 → 估算 token → 查当时的价格 / 分组倍率 / 计费表达式
2. 把这份"价格快照"挂在请求 context 上
3. 按快照预扣额度
4. 请求完成 → 拿上游真实 usage → 用同一份快照重新计算 → 退还或补扣差额
5. 写日志,把快照内容也记下,便于事后核账

这样即使请求执行的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 工具元数据
type ToolMetadata struct {
Name string
Type ToolType // local / http / mcp / external
Schema JSONSchema
AllowedAgents []string // 空数组 = 所有 agent 可用;非空 = 白名单
FeatureGate string // 功能开关 key,未开启则不可见
RequiredScopes []string // OAuth-style 权限范围
}

// 准入检查
func (r *ToolRegistry) GetToolsByAgent(agentType string) []Tool {
var allowed []Tool
for _, t := range r.all {
if !t.FeatureGate.Enabled() { continue } // 1. feature gate
if len(t.AllowedAgents) > 0 &&
!slices.Contains(t.AllowedAgents, agentType) {
continue // 2. agent 白名单
}
allowed = append(allowed, t)
}
return allowed
}

工具准入是这样,知识库 / 记忆段也是同一套思路 —— 都是”声明谁能看 + 运行时检查”的组合。

三个反复踩过的坑:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
Lifecycle:  idle ──▶ running ──▶ stopping ──▶ stopped

│ 仅 running 期间才有 AgentStatus 转换

AgentStatus: new ──▶ planning ──▶ executing ──▶ reporting ──▶ complete
│ │
└─▶ failed ◀───┘
└─▶ canceled

executing 期间才有 StepStatus 转换

StepStatus: pending ──▶ awaiting_user ──▶ executing ──▶ done

└──▶ error

为什么非要分三层?

因为它们的”可恢复性”不一样。Lifecycle 是进程级的,crash 就没了,必须靠外部重启;AgentStatus 是业务级的,可以 checkpoint 到 Redis,重启后能恢复;StepStatus 是 turn 级的,通常只在内存里,turn 一结束就清。把它们混成一个 enum,要么状态爆炸(笛卡尔积一下变 50+ 个状态),要么恢复语义一塌糊涂。

工程上有几个铁律:

1. 所有状态转换必须经过 CanTransitionTo() 校验new → complete 直接跳是不允许的,canceled → running 也是不允许的。把校验集中在一个地方,不是散落在 20 个 if 里。

2. 每次转换都要发事件。状态从 planning → executing 要发一个 AgentStatusChanged 事件,前端可以渲染、监控可以打点、审计可以记录。**不要做”静默转换”**,那是事故的温床。

3. 终态是 sinkcomplete / failed / canceled / stopped 进去就出不来。任何”试图从终态再转出”的代码都要直接拒绝。

4. 中间态要有超时planning 卡 30 分钟基本就是死循环了,必须有 watchdog 强制转 failed 并报警。


5. 后端 agent 是否支持多租户并发调用?

支持,但实现思路和”web 服务多租户”不太一样。

普通 web 服务的多租户是 stateless + 横向扩展:每个请求独立、共享的资源是数据库 / 缓存。但 agent 服务是 有状态的长连接 + 复杂调度 —— 一个 agent run 可能跑十几分钟、中间会有十几个 step、还要保持上下文。这意味着:

  • 不能 per-request 起新 agent 实例(每次重新装载工具、记忆、状态太重)
  • 也不能全局只有一个 agent 实例(多租户并发会互相污染)

业界常见的折中是 per-Run 实例化 + 注册表索引 + 跨节点路由

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
请求进来


┌─────────────────────────┐
│ RouteService.OnMessage │
└──────────┬──────────────┘

├─ Run 已存在?
│ ├─ 在本机:local registry → agent.OnEvent(msg)
│ └─ 在他机:Redis 查 location → HTTP 转发

├─ Run 不存在但有 checkpoint?
│ └─ FactoryProvider.Recover(state) → 注册到本机

└─ 全新 Run?
└─ FactoryProvider.Build(ctx) → 注册到本机 + 写 Redis

四个关键设计:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
┌──────────────┐
│ 1. 认证网关 │ ← 单一信任源
│ ───────── │
│ 解 token │
│ 写 ctx: │
│ userID │
│ projectID │
│ orgID │
└──────┬───────┘
│ 透传 ctx

┌──────────────────────┐
│ 2. 应用层 / Domain │
│ ───────────── │
│ CreateSessionCmd { │
│ UserID: ctx.uid │ ← 命令对象强制带上
│ } │
│ │
│ LoadSession(id): │
│ if s.UserID != uid │ ← 取 session 时强制校验
│ return Err403 │
└──────┬───────────────┘


┌──────────────────────┐
│ 3. 持久化层 │
│ ──────────── │
│ table sessions { │
│ user_id NOT NULL │ ← 数据库约束
│ INDEX(user_id) │
│ } │
│ table runs { │
│ session_id │
│ user_id │ ← 冗余写入,便于审计
│ } │
└──────────────────────┘

四个常被忽视的细节:

1. ctx 注入只能由网关来做,不能让业务层自己塞。如果某个 controller 写了 ctx.WithUserID("xxx"),整个权限模型就破了 —— 任何人能调到这个接口就能伪装。强制网关做、业务层只读不写

2. Session 取出来必须校验 ownerSELECT * 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
[1] Client                  POST /chat (SSE) {message, sessionId}

[2] Gateway 解 token, 注入 user/project/org 到 ctx

[3] Controller setupSSE, bind ChatRequest, hand off to AppService

[4] AppService loadSession+校验owner → ProcessChat command

[5] DomainService.Process 构造 Run 实体, persist, dispatch event

[6] MQ (optional) 解耦同步链路, 让 controller 早点 return

[7] RouteService 按 runID 找 Runner(本机 / 跨节点 / 新建 / 恢复)

[8] Runner.OnEvent 驱动 Agent 状态机

[9] Agent.processCurrent 根据 AgentStatus 决定下一步
↓ ┌──── planning → 调 LLM 出计划
├──── executing → 调 Executor.Step
└──── reporting → 调 LLM 总结
[10] Executor.Step 调 ToolNode.Invoke(toolCall)

[11] ToolNode 调度具体 Tool 实现(local / mcp / http)

[12] Tool 返回 result Executor 把 result 回灌成 message

[13] 回到 [9] 再来一轮 直到 AgentStatus = complete / failed

[14] 全程通过 EventStream 每个边界点向 SSE channel 发事件

这条链路里有几个只有踩过坑才知道的设计点:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
最新 N 个 turn 完整保留    ←—— 短期记忆主体
─────────────────
Turn N
Turn N-1
Turn N-2
...

----- 滑动窗口边界 -----

Turn N-W ~ Turn 1 ←—— 摘要替换
─────────────────
这部分用一段 LLM 生成的摘要 / 或多段增量摘要顶替
原始 message 被归档但不进 prompt

----- 跨 session 边界 -----

外部向量库 / 知识库 ←—— 长期记忆
─────────────────
按当前 query 检索 top-K
以 RAG 形式注入 prompt

工程上常见的几个细节:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Client                                        Server
│ │
│ ── GET /chat/stream Accept:text/event-stream│
│ ───────────────────────────────────────────▶│
│ │
│ ◀── 200 OK │
│ Content-Type: text/event-stream │
│ Cache-Control: no-cache │
│ Connection: keep-alive │
│ X-Accel-Buffering: no ← 关 Nginx 缓冲│
│ Access-Control-Allow-Origin: ... │
│ │
│ ◀── event: RunStarted\ndata: {...}\n\n │
│ ◀── event: TextMessageStart\ndata: {...}\n\n│
│ ◀── event: TextMessageContent\ndata:{...}\n\n
│ ◀── event: TextMessageContent\ndata:{...}\n\n
│ ◀── ... │
│ ◀── :heartbeat\n\n ← 注释行做心跳 │
│ ◀── event: ToolCallStart\ndata: {...}\n\n │
│ ◀── event: ToolCallEnd\ndata: {...}\n\n │
│ ◀── event: RunFinished\ndata: {...}\n\n │
│ │
│ ── (close TCP) │

要让这条链路在生产稳定,下面这些不是”建议”,是必做

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. 客户端断线服务端必须立刻感知并停 agentreq.Context().Done() 监听 → 立刻发取消信号到 agent。不要继续生成事件喂到无人接收的 channel —— 不光浪费算力,还会泄漏 token 钱。

5. 服务端主动结束要发显式终止事件。比如 RunFinishedRunError,让前端知道是”正常结束”还是”出错了”,不要靠 TCP 断开来推断。


10. 后端推流通常采用什么数据格式?

SSE 的 wire format 是给定的(event: xxx\ndata: <string>\n\n),但 data 里到底放什么 —— 这一层每家都自己造过轮子,直到 AG-UI Protocol 出现把它收敛成行业事实标准。

AG-UI 推荐的格式是 JSON 包裹的 type-discriminated event

1
2
3
4
5
event: TEXT_MESSAGE_CONTENT
data: {"type":"TEXT_MESSAGE_CONTENT","timestamp":1715251200000,"messageId":"msg_42","delta":"hello "}

event: TEXT_MESSAGE_CONTENT
data: {"type":"TEXT_MESSAGE_CONTENT","timestamp":1715251200300,"messageId":"msg_42","delta":"world"}

每个 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+ 个具体事件。规范的事件名是 PascalCaseRunStarted),但在 SSE 序列化时通常转成 SNAKE_CASERUN_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 1. 一次 run 开始
{
"type": "RUN_STARTED",
"timestamp": 1715251200000,
"threadId": "thread_abc", // 会话/线程 ID(跨多个 run 复用)
"runId": "run_001", // 本次 run 的唯一 ID
"parentRunId": "run_000", // 可选;用于分支 / 时间旅行场景指向父 run
"input": { "messages": [...] } // 可选;本次 run 的完整输入
}

// 2. 一个具名子步骤开始(如 "planning" / "executing" / "reporting")
{
"type": "STEP_STARTED",
"timestamp": 1715251200100,
"stepName": "planning"
}

// 3. 子步骤结束(必须和某个 STEP_STARTED 配对)
{
"type": "STEP_FINISHED",
"timestamp": 1715251205000,
"stepName": "planning"
}

// 4. run 正常结束
{
"type": "RUN_FINISHED",
"timestamp": 1715251220000,
"result": { "summary": "..." }, // 可选;放到事件根,向后兼容
"outcome": { // 可选;新版语义化结果
"type": "success" // "success" 或 "interrupt"
}
}

// 4'. 如果 run 是被中断而不是跑完,outcome 长这样
{
"type": "RUN_FINISHED",
"timestamp": 1715251220000,
"outcome": {
"type": "interrupt",
"interrupts": [ // 中断详情,例如等待用户确认
{ "kind": "user_confirm", "data": {...} }
]
}
}

// 5. run 出现不可恢复错误
{
"type": "RUN_ERROR",
"timestamp": 1715251210000,
"message": "model timeout after 30s",
"code": "MODEL_TIMEOUT" // 可选错误码
}

工程上几个反复踩过的点:

  • threadIdrunId 一定要分清threadId 跨多轮对话复用(约等于 session),runId 是单次 agent 运行(一个 thread 通常会有多个 run)。前端要把同一个 thread 下的 run 拼成完整对话。
  • parentRunId 是树状结构的钥匙。子 agent 调用 / 重试 / 时间旅行回退都靠它建立 run 之间的父子关系。
  • RunFinishedoutcome 是新版字段,老版本只有顶层 result实现端要兼容两种:旧 producer 不发 outcome,新 consumer 不能假设它一定存在。
  • StepStarted / StepFinished 必须严格配对。前端的 step 进度条、loading 态全靠它,缺一个就错乱。

Text Message 四件套:流式文本输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 1. 开始一条流式 message
{
"type": "TEXT_MESSAGE_START",
"timestamp": 1715251200000,
"messageId": "msg_42",
"role": "assistant" // 可选: developer / system / assistant / user / tool
}

// 2. 文本片段(可以发很多次)
{
"type": "TEXT_MESSAGE_CONTENT",
"timestamp": 1715251200100,
"messageId": "msg_42",
"delta": "hello " // 必须非空
}

// 3. message 结束
{
"type": "TEXT_MESSAGE_END",
"timestamp": 1715251200500,
"messageId": "msg_42"
}

// 4. 简化版:一次发送 chunk,自动展开为 START → CONTENT → END
{
"type": "TEXT_MESSAGE_CHUNK",
"timestamp": 1715251200000,
"messageId": "msg_42", // 第一个 chunk 必填
"role": "assistant", // 第一个 chunk 必填
"delta": "hello world" // 可选;空字符串关闭 message
}

Tool Call 五件套:本节的核心

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 1. 开始一次工具调用
{
"type": "TOOL_CALL_START",
"timestamp": 1715251200000,
"toolCallId": "call_abc123", // 全局唯一
"toolCallName": "search_web", // 工具名
"parentMessageId": "msg_42" // 可选;这个 tool call 挂在哪条 message 上
}

// 2. 参数流式(参数较长时按片段流出)
{
"type": "TOOL_CALL_ARGS",
"timestamp": 1715251200100,
"toolCallId": "call_abc123",
"delta": "{\"query\":\"agent arch" // 参数 JSON 片段,前端拼接
}

// 3. 参数发送完毕
{
"type": "TOOL_CALL_END",
"timestamp": 1715251200200,
"toolCallId": "call_abc123"
}

// 4. 工具结果回灌
{
"type": "TOOL_CALL_RESULT",
"timestamp": 1715251205000,
"messageId": "msg_43", // 工具响应自己也是一条 message
"toolCallId": "call_abc123",
"content": "[{\"title\":\"...\"}]", // 结果,字符串或序列化后的结构化数据
"role": "tool" // 可选;OpenAI / Anthropic 协议的 role
}

// 5. 简化版:一次发送 chunk,自动展开为 START → ARGS → END
{
"type": "TOOL_CALL_CHUNK",
"timestamp": 1715251200000,
"toolCallId": "call_abc123", // 第一个 chunk 必填
"toolCallName": "search_web", // 第一个 chunk 必填
"parentMessageId": "msg_42", // 可选
"delta": "{\"query\":\"...\"}" // 可选参数片段
}

State 三件套:共享状态同步

很多 agent 需要前后端共享一份”工作区状态”(当前打开的文件、已选中的元素、表单数据……)。AG-UI 用 快照 + 增量补丁 的方式同步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 1. 全量快照:替换整个 state
{
"type": "STATE_SNAPSHOT",
"timestamp": 1715251200000,
"snapshot": { "openFile": "main.go", "cursor": 42 }
}

// 2. 增量更新:RFC 6902 JSON Patch
{
"type": "STATE_DELTA",
"timestamp": 1715251200500,
"delta": [
{ "op": "replace", "path": "/cursor", "value": 100 },
{ "op": "add", "path": "/selection", "value": [10, 20] }
]
}

// 3. 完整 message 历史快照(重连场景常用)
{
"type": "MESSAGES_SNAPSHOT",
"timestamp": 1715251200000,
"messages": [ {...}, {...}, {...} ]
}

Activity 两件套:结构化活动消息

Activity 是比纯文本更结构化的”过程展示”,比如 planner 的计划、search agent 的搜索结果列表 —— 前端要按特定 UI 模板渲染。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 1. 整个 activity 的完整快照
{
"type": "ACTIVITY_SNAPSHOT",
"timestamp": 1715251200000,
"messageId": "act_01",
"activityType": "PLAN", // 判别符:PLAN / SEARCH / ...
"content": {
"steps": [
{ "id": 1, "title": "...", "status": "pending" },
{ "id": 2, "title": "...", "status": "pending" }
]
},
"replace": true // 可选;默认 true
}

// 2. activity 增量更新
{
"type": "ACTIVITY_DELTA",
"timestamp": 1715251200500,
"messageId": "act_01",
"activityType": "PLAN",
"patch": [
{ "op": "replace", "path": "/steps/0/status", "value": "done" }
]
}

Reasoning 七件套:思考过程的流式

针对 OpenAI o1 / Claude thinking / DeepSeek-R1 这种”推理模型”的内心独白,AG-UI 给了独立的事件通道。和 TextMessage 严格分开,前端通常折叠展示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 1. 开始一段推理上下文
{ "type": "REASONING_START", "timestamp": ..., "messageId": "rsn_01" }

// 2. 开始一条可见的推理消息
{ "type": "REASONING_MESSAGE_START", "timestamp": ..., "messageId": "rsn_msg_01", "role": "reasoning" }

// 3. 推理内容流式片段
{ "type": "REASONING_MESSAGE_CONTENT", "timestamp": ..., "messageId": "rsn_msg_01", "delta": "let me think..." }

// 4. 推理消息结束
{ "type": "REASONING_MESSAGE_END", "timestamp": ..., "messageId": "rsn_msg_01" }

// 5. 简化 chunk 版(自动管理 message 生命周期,空 delta 关闭 message)
{ "type": "REASONING_MESSAGE_CHUNK", "timestamp": ..., "messageId": "rsn_msg_01", "delta": "let me..." }

// 6. 整段推理上下文结束
{ "type": "REASONING_END", "timestamp": ..., "messageId": "rsn_01" }

// 7. 加密的思维链(O 系列模型不暴露明文 reasoning,只给加密 blob 用于后续轮次)
{
"type": "REASONING_ENCRYPTED_VALUE",
"timestamp": 1715251200000,
"subtype": "message", // "message" 或 "tool-call"
"entityId": "msg_42",
"encryptedValue": "<opaque base64 ...>"
}

Special 三件套:透传 / 自定义 / 元事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Raw —— 把外部系统的事件原样包一层
{
"type": "RAW",
"timestamp": ...,
"event": { /* 原始数据 */ },
"source": "openai"
}

// Custom —— 业务自定义扩展
{
"type": "CUSTOM",
"timestamp": ...,
"name": "drone_status_changed",
"value": { "battery": 87 }
}

// MetaEvent (draft) —— 旁路注解事件,独立于 run
{
"type": "META_EVENT",
"timestamp": ...,
"metaType": "thumbs_up",
"payload": { "messageId": "msg_42" }
}

已弃用: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 跑稳的最低成本。


参考资料

Ask Leslie

从本站公开文章中寻找答案。当前版本在浏览器本地检索,不上传问题,也不会编造不存在的经历。

输入一个问题,我会把你带到 Leslie 写过的相关内容。

微信联系

Leslie Zhang 的微信二维码

扫码添加 Leslie,建议备注你的名字与来意。