前言 :封闭告一段落,开始系统复盘:一个生产 Agent 到底怎么跑起来。踩坑、经验和落地,最主要的是用户认可。尽量用几篇文章讲清,这篇文章是系列第6篇:Agent 挂掉常常不是模型傻,而是身份传丢了、会话只放在内存里(重启或换机器就没了)、用户点了取消但模型和工具请求还在跑。这篇把会话服务当成能落地的底子。
线上常叠在一起的几件事:
- 用户连点发送,后台起了两个任务;
- 点了取消,模型和工具还在跑、调用费还在产生;
- 换手机打开,刚才的会话找不着;
- 工具居然拿模型参数里塞的
userId当登录身份来用。
这篇只讲怎么把这四件事收住:
- 谁在聊:租户(一家公司 / 一个客户在系统里的隔离边界)、用户从登录态来,写进数据库里的会话和 Run(用户点一次发送,后台为这句话开的一次任务);
- 用谁的权限调工具 :整条链路带上同一份请求上下文,不要使用 模型参数里的
userId; - 同一次点击不要起两个任务:双击发送、网络重试,要回到同一条 Run;
- 取消怎样才算真的停:不只关掉推流,还要打断正在跑的模型和工具请求。
继续用案例 OpsPilot(企业运维助手,虚构案例),技术栈仍是 TypeScript;Go / Java / Node 都能做,关键是边界清楚:不要让前端直连模型 Key。
会话服务到底管什么
会话服务至少做这五件事:
- 会话与消息写进数据库(创建、追加、分页拉取)
- 一次 Run 的生命周期(排队、执行、等人确认、完成、失败、取消)
- 把登录态变成显式上下文 ,交给编排层和工具层(不要使用模型参数里的
userId) - 同一次发送不要起两个 Run(防双击、防网络重试)
- 取消要能打断正在跑的模型和工具请求
即使用 LangGraph 这类框架跑 Agent 循环,租户、登录态、取消、跟前端的接口,仍归会话服务。框架只负责把图跑到哪一步记下来。这次任务进行到哪、能不能取消,以会话服务里的 Run 为准,不要两套状态各说各的。
请求上下文:整条链路只信这一份
请求进会话服务时,先根据登录态拼出一份 RequestContext 对象数据。后面编排、工具调用都只认这份对象(需要时在链路里传它的引用),不要再从零散的 Header 里抠身份,更不要使用模型参数里写的「我是谁」:
ts
type RequestContext = { // 入口鉴权后的请求上下文
requestId: string // 本请求 id,日志与排障用
traceId: string // 全链路追踪 id,会话 / 工具 / 模型共用
tenantId: string // 租户;查库、检索过滤以此为准
userId: string // 当前用户;工具鉴权读这里
roles: string[] // 角色,用于权限与可见范围
scopes: string[] // 允许调用的工具或资源范围
authHeader?: string // 仅服务端持有,不进 Prompt、不进日志明文
locale?: string // 可选:语言 / 地区
deadlineAt?: number // 这次 Run 的截止时间,到点应取消
}
这份对象怎么用,就记三句:
- 给模型拼 Prompt、做知识检索过滤时,用里面的
tenantId/roles,决定「能看见哪些数据」。 - 调业务接口时,用服务端自己保存的访问凭证(一串证明「你是谁、有没有权限」的密钥,常放在
Authorization头里)去调;需要「以当前用户身份去调」时,也由服务端换成对的凭证,不要让模型在参数里填这串密钥。 - 打日志时,访问凭证只留 hash 或后几位,完整内容不要写进日志。
时序可以看成:
工具 Agent Runtime 会话服务 前端 工具 Agent Runtime 会话服务 前端 #mermaid-svg-PgmQcf14H9RIpnmE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PgmQcf14H9RIpnmE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PgmQcf14H9RIpnmE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PgmQcf14H9RIpnmE .error-icon{fill:#552222;}#mermaid-svg-PgmQcf14H9RIpnmE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PgmQcf14H9RIpnmE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PgmQcf14H9RIpnmE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PgmQcf14H9RIpnmE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PgmQcf14H9RIpnmE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PgmQcf14H9RIpnmE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PgmQcf14H9RIpnmE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PgmQcf14H9RIpnmE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PgmQcf14H9RIpnmE .marker.cross{stroke:#333333;}#mermaid-svg-PgmQcf14H9RIpnmE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PgmQcf14H9RIpnmE p{margin:0;}#mermaid-svg-PgmQcf14H9RIpnmE .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PgmQcf14H9RIpnmE text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-PgmQcf14H9RIpnmE .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-PgmQcf14H9RIpnmE .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-PgmQcf14H9RIpnmE .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-PgmQcf14H9RIpnmE .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-PgmQcf14H9RIpnmE #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-PgmQcf14H9RIpnmE .sequenceNumber{fill:white;}#mermaid-svg-PgmQcf14H9RIpnmE #sequencenumber{fill:#333;}#mermaid-svg-PgmQcf14H9RIpnmE #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-PgmQcf14H9RIpnmE .messageText{fill:#333;stroke:none;}#mermaid-svg-PgmQcf14H9RIpnmE .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PgmQcf14H9RIpnmE .labelText,#mermaid-svg-PgmQcf14H9RIpnmE .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-PgmQcf14H9RIpnmE .loopText,#mermaid-svg-PgmQcf14H9RIpnmE .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-PgmQcf14H9RIpnmE .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-PgmQcf14H9RIpnmE .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-PgmQcf14H9RIpnmE .noteText,#mermaid-svg-PgmQcf14H9RIpnmE .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-PgmQcf14H9RIpnmE .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PgmQcf14H9RIpnmE .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PgmQcf14H9RIpnmE .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-PgmQcf14H9RIpnmE .actorPopupMenu{position:absolute;}#mermaid-svg-PgmQcf14H9RIpnmE .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-PgmQcf14H9RIpnmE .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-PgmQcf14H9RIpnmE .actor-man circle,#mermaid-svg-PgmQcf14H9RIpnmE line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-PgmQcf14H9RIpnmE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /chat读历史、开始 Run进度事件SSE 推进度带 RequestContext 调工具工具结果POST /cancel取消 Run(写库 + abort)
再记住三件和 ID、取消、历史有关的事:
- 一个会话用固定的
conversationId;用户每点一次发送,新建一个runId。 - 取消必须带上这个
runId,说清楚停的是哪一次任务,不要只丢一句「停一下」。 - 聊天列表里给用户看的历史消息,和这次 Run 跑模型、调工具时的中间过程分开存。
鉴权、多租户与凭证
身份校验要分三层做,不能只在门口验一次:
| 层 | 干什么 |
|---|---|
| 网关 / 会话服务 | 核对登录是否有效,拼出 RequestContext,必要时限流 |
| Agent Runtime(编排执行) | 按 scopes 决定模型能看见哪些工具;把 RequestContext 原样传给真正跑工具的代码 |
| 工具实现 | 再按业务规则验一遍「这个用户能不能做这件事」;写操作记下谁在什么时候做了什么 |
常见事故是:入口验过登录,工具里却拿模型参数里的 userId 来鉴权。模型一旦被诱导改掉这个字段,就会越权。工具参数里的用户身份不要使用 ,只认 RequestContext。
查库必带租户
租户 id、用户 id 要写进表,并且每次查询都带上,不能只靠前端「别请求别人的会话 id」这种自觉:
sql
CREATE TABLE conversations (
id UUID PRIMARY KEY, -- 会话 id
tenant_id TEXT NOT NULL, -- 租户;查询必带
user_id TEXT NOT NULL, -- 所属用户;查询必带
title TEXT NOT NULL, -- 会话标题,如「排障 - payment-api」
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 按租户 + 用户列会话列表时用
CREATE INDEX conversations_tenant_user_idx
ON conversations (tenant_id, user_id, created_at DESC);
查会话时不要提供「只按 conversationId 查」的函数。别人若猜到或泄露了会话 id,就能串到别的对话。正确写法是三个条件一起匹配:
- conversationId
- 租户id
- 用户id
ts
type ConversationScope = {
tenantId: string
userId: string
}
async function getConversation(
db: Database,
scope: ConversationScope,
conversationId: string
) {
// id + 租户 + 用户一起匹配,缺一不可
return db.oneOrNone(
`SELECT * FROM conversations
WHERE id = $1 AND tenant_id = $2 AND user_id = $3`,
[conversationId, scope.tenantId, scope.userId]
)
}
不止聊天表,下面这些地方也要用同一套隔离,否则会从别的口子漏数据:
- 知识检索(向量 / 全文):在服务端查询条件里就带上租户,不要查完再滤
- 长期记忆的读写 key
- 工具去调业务接口时带上的身份信息
- 链路追踪记录、运营后台谁能看哪些租户
租户条件要写在数据库 / 检索的查询里,不要先不加隔离把相关数据全查出来,再在应用代码里把别人的数据扔掉。那样一来,没权限的内容可能已经进了排序、日志或模型上下文。
管理员要跨租户排查问题时,走单独的权限和接口,强制填写原因并记谁查了什么;不要复用普通聊天接口。用户注销或租户停用后,会话、记忆、上传文件和检索索引都要能删干净,并且删没删得掉可以核对。
各种密钥不要进 Prompt
密钥、访问凭证只放在服务端,不要 写进发给模型的 Prompt,也不要写进会存进消息历史的正文里:
| 东西 | 放哪、怎么用 |
|---|---|
| 调大模型用的 API Key | 放在密钥管理系统或服务启动环境变量里,只有会话服务 / Runtime 能读 |
| 用户的访问凭证 | 会话服务在内存或短时缓存里拿着去调业务接口;不要塞进聊天记录 |
| 某个租户自己的插件密钥 | 按租户加密存库,真正调插件时再解密;别进 Prompt |
| 调内部业务系统时的身份 | 由服务端根据 RequestContext 换成对的凭证再去调;工具里再验一遍权限 |
再说一次 :模型工具参数里的 userId、tenantId、roles 不要使用 ;工具应忽略这些字段,只认 RequestContext。
起 Run:同一次点击只要一个任务
用户点发送时,请求体里至少带三样:会话 id、用户原话、一个请求键(由客户端在用户点发送时生成,浏览器或 App 都行;同一句发送、连点、自动重试都带同一个值。服务端不要每次请求自己新造一个,否则对不上)。
短时间内同一个请求键,必须回到同一条 Run。否则双击发送或网络抖一下,后台会起两个循环,模型和工具各跑两遍,又贵又危险。
ts
type StartChatInput = {
conversationId: string
idempotencyKey: string // 请求键;短时间内相同则返回同一条 Run
message: string // 用户本轮输入
}
Agent 可能跑很久,不要让「接收发送请求」的那次 HTTP 一直卡着把模型、工具全跑完。建议拆成两步:
- 先登记这次任务 :往数据库插入一条 Run。表上对「同一个租户 + 同一个请求键」加唯一限制。插成功就是新任务;插失败说明这个键已经用过,直接返回已有的
runId,不要再开一条。 - 再交给后台去跑 :把
runId丢进队列(或同类后台任务),由别的进程慢慢调模型、调工具。接收请求的这边只负责打开 SSE,把进度推给前端。
sql
CREATE TABLE runs (
id UUID PRIMARY KEY, -- Run id
tenant_id TEXT NOT NULL, -- 租户;与请求键一起做唯一约束
conversation_id UUID NOT NULL, -- 落在哪个会话
idempotency_key TEXT NOT NULL, -- 客户端带来的请求键;短时间内同键同 Run
status TEXT NOT NULL, -- queued / running / interrupted / ...
cancel_requested BOOLEAN NOT NULL DEFAULT FALSE, -- 用户是否已点取消;后台每步要看
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, idempotency_key) -- 同一租户下,同键只能有一条 Run
);
ts
// 步骤 1:登记 Run(同租户同请求键插第二次会失败,应改成查出已有 runId 返回)
// 步骤 2:通知后台去跑;本函数很快返回,不在这里跑完整 Agent
async function startRun(input: StartChatInput, ctx: RequestContext) {
const run = await db.insertRun({
tenantId: ctx.tenantId,
conversationId: input.conversationId,
idempotencyKey: input.idempotencyKey,
status: 'queued' // 已登记,等后台来执行
})
await queue.publish('agent.runs', { runId: run.id, tenantId: ctx.tenantId })
return run
}
超时怎么分层(建议):
- 模型调用超时:会话服务把这次 Run 标成失败或部分完成,用 SSE 推一条错误事件给前端。
- 工具调用超时:由真正跑工具的那一层决定是重试还是把失败写回对话让模型换办法。会话服务不要自己偷偷无限重试,这样调用费容易失控。
取消要真能停下模型和工具请求
SSE / HTTP 连接断了,只表示前端暂时听不到进度,不等于 用户点了取消,也不等于后台已经停。
用户要点取消,前端应打明确的取消接口(不要指望「关掉推流」就够了)。服务端做两件事:
- 把「用户要取消」写进数据库(
cancel_requested = true),后台跑模型 / 工具的每一步都能读到; - 找到正在跑这次 Run 的后台任务,调用中断(常见是
abort()),打断已经发出去的模型请求和工具 HTTP。
ts
async function cancelRun(runId: string, ctx: RequestContext) {
// 1) 取消意图写进数据库,换机器 / 下一步循环也能看见
await db.markCancelRequested(runId, ctx.tenantId)
// 2) 打断本机正在跑的任务(若任务在别的机器,靠第 1 步的库字段也能停)
const worker = workers.get(runId)
worker?.abort()
}
后台每一步都要同时看:库里的 cancel_requested,以及本次调用的中断信号(如 AbortSignal)。前端取消路径建议固定为 POST /runs/:id/cancel,否则容易出现「页面已停,模型调用费还在产生」。
任务可以交给后台队列去跑,但这次任务进行到哪、进度怎么推、谁有权限,仍以会话服务里的 Run 为准。不要另外再建一张任务表、却不带租户字段,否则隔离和取消都会对不上。
对外接口与排查
对外最少准备这几个接口(路径名可按你们规范改,意思别丢):
- 拉历史:
GET /conversations/:id/messages - 发起一次 Run:
POST /conversations/:id/runs,进度用 SSE 推 - 人确认后恢复:
POST /runs/:id/resume - 取消:
POST /runs/:id/cancel
Run 状态建议固定用这几个:queued(已登记排队)、running(执行中)、interrupted(暂停等人)、completed(正常结束)、failed(失败)、cancelled(已取消)。前端按这套状态和 SSE 业务事件更新页面即可,不要去解析半截内部日志来猜进度。
出问题先查(也是最常见的踩坑):
- 换手机 / 换浏览器后续不上刚才的对话:会话、Run、取消意图是不是只放在内存里,没有写进数据库(一扩容或一重启就没了)。
- 工具返回 401,或工具越权用了别人的身份 :
RequestContext有没有传到工具;访问凭证是不是过期了;有没有误用模型参数里的userId/tenantId(身份只认登录态拼出来的上下文)。 - 用户点了取消,调用费还在产生:是不是只关掉了 SSE;取消有没有写进数据库,模型和工具的 HTTP 有没有被真正打断。
- 能看到别人租户或别人的会话 :查询是不是只按
conversationId;有没有同时带上tenantId/userId。 - 连点发送起了两个任务:起 Run 时有没有用同一个请求键占位;同键是否只对应一条 Run。
小结
会话服务要立住,核心就三块:身份从哪来、一次发送怎么起停、数据怎么隔开。
- 身份只认登录态拼出的
RequestContext。 网关 / 会话服务验登录后整条链路带着这份对象;工具里不要使用模型参数里的userId/tenantId,并再按业务规则验一遍「能不能做这件事」。密钥和访问凭证只放服务端,别进 Prompt、别进聊天记录。 - 一次点击对应一条 Run;取消要真能停。 客户端带上同一个请求键,短时间内同键只起一条任务,先写进数据库再丢给后台跑。用户点取消:取消意图写进数据库,并打断正在跑的模型和工具请求。关掉 SSE 只是前端听不见进度,不等于任务已停。
- 租户(和用户)写进查询条件,不是事后过滤。 查会话、检索、记忆读写都带上隔离字段;不要先把相关数据全查出来,再在应用代码里扔掉别人的。另建后台任务表时也要带租户,状态仍以会话服务里的 Run 为准。
希望这篇文章对你有所帮助、有所借鉴。