🚀🚀【封闭结束6】Agent 会话服务与鉴权上下文,让生产环境中的 Agent 真正可控

48 阅读22分钟

前言:封闭告一段落,开始系统复盘:一个生产 Agent 到底怎么跑起来。踩坑、经验和落地,尽量用几篇文章讲清,这篇文章是系列第6篇:Agent 挂掉常常不是模型傻,而是身份传丢了、会话只放在内存里(重启或换机器就没了)、用户点了取消但模型和工具请求还在跑。
希望这篇文章能给正在做 Agent 落地的朋友一些参考,也欢迎大家在评论区交流自己的实践。

线上生产环境中,下面这些问题经常会叠在一起:

  • 用户连续点击发送,后台启动了两个任务,模型和工具被重复调用。
  • 点了取消,前端不再显示结果,但模型和工具仍在运行,调用费用还在产生。
  • 换手机或浏览器打开应用,发现刚才的会话不见了。
  • 工具居然把模型参数里传入的 userId 当成真实登录身份,导致鉴权边界被绕过。

这些问题看起来互不相关,实际上都指向同一个核心:Agent 不只是一次模型调用,它还需要一套可靠的会话、身份和任务生命周期管理机制。

这篇主要讲清楚四件事:

  1. 谁在聊:租户(一家公司或一个客户在系统中的隔离边界)和用户身份必须来自可信的登录态,并正确写入数据库中的会话与 Run(可以理解为用户点击一次发送后,后台为本轮请求创建的一次任务)。
  2. 用谁的权限调工具:整条调用链路都要传递同一份可信请求上下文,不能使用模型参数里的 userId 来确定当前用户身份。
  3. 同一次点击不要起两个任务:无论是用户双击发送,还是网络超时后自动重试,都应该回到同一条 Run。
  4. 取消怎样才算真的停:不能只关闭前端推流,还要尽可能中断正在执行的模型请求和工具请求,并确认任务最终进入正确的状态。

继续使用案例 OpsPilot(虚构的企业运维助手),技术栈仍然是 TypeScript。Go、Java、Node.js 等技术栈同样可以实现,关键是把职责和边界划清楚:不要让前端直接持有模型 API Key。

Snipaste_2026-10-27_16-01-34.png

会话服务到底管什么?

会话服务至少要负责五件事:

  1. 会话与消息持久化:将会话和消息写入数据库,支持创建、追加和分页查询。
  2. Run 的生命周期管理:管理任务排队、执行、等待人工确认、完成、失败和取消等状态。
  3. 可信身份上下文的传递:将登录态转换为明确的请求上下文,并传递给编排层和工具层,不能使用模型参数里的 userId。
  4. Run 的幂等创建:防止用户双击、网络重试等情况导致同一轮请求被重复执行。
  5. 任务取消与中断:不仅要通知前端停止显示,还要尽可能中断正在执行的模型和工具请求。

为什么要单独强调会话服务?

因为在不同的 Agent 应用场景中,它承担的责任并不一样。

  • 普通聊天助手:重点是历史消息持久化、跨设备恢复和多用户隔离。
  • 企业知识库助手:除了保存会话,还必须确保每次检索都遵守租户和用户的数据权限。
  • 运维 Agent 或自动化 Agent:可能会执行重启服务、修改配置、调用内部 API 等操作。此时,重复执行和取消失效就不只是体验问题,还可能影响真实业务。
  • 需要人工审批的 Agent:任务可能暂停几分钟甚至几小时。系统必须能保存状态,并在人工确认后准确恢复,而不是重新执行整条任务。

这些场景的业务逻辑不同,但底层都需要可靠的会话和任务管理。

即使使用 LangGraph 这类框架运行 Agent 循环,租户隔离、登录态、取消机制以及与前端的接口,仍然属于会话服务需要解决的问题。框架可以负责记录图执行到哪一步,但任务是否正在运行、是否已取消,以及当前用户是否有权操作这条任务,都必须有明确、可信的管理依据。

不要让框架状态和业务数据库里的 Run 状态各说各的,否则排查问题时很容易出现:框架认为任务还在运行,前端却已经显示取消成功。

请求上下文:整条链路只信这一份

请求进入会话服务后,首先要根据已经验证的登录态,构建一份 RequestContext 对象。后续编排和工具调用都以这份可信上下文为准,必要时通过参数显式传递;不要在各层重新从零散的 Header 中拼凑身份,更不能相信模型自己声明的「我是谁」。

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 等可信信息,决定当前用户可以访问哪些数据。注意,权限过滤不能只靠 Prompt 告诉模型「不要看」,而要在数据库或检索层真正执行。
  • 控制工具能做什么:调用业务接口时,使用服务端持有或根据当前用户身份换取的访问凭证。若需要以当前用户身份执行操作,应由服务端完成凭证转换和权限校验,不能让模型在参数里填写访问密钥。
  • 控制敏感信息如何流转:记录日志时,只保留必要的凭证标识、hash 或脱敏后的尾部信息,不能把完整访问凭证写进日志。

这里有一个容易忽略的细节:RequestContext 是服务端内部可信的数据结构,不代表里面的每个字段都应该直接传给模型,也不代表每个字段都可以无条件信任。

例如,tenantId 和 userId 应由认证结果确定;roles 和 scopes 应来自可信的授权逻辑;traceId 可以用于追踪请求,却不能用来判断用户是否有权限。

时序可以看成:

sequenceDiagram
    participant C as 前端
    participant S as 会话服务
    participant R as Agent Runtime
    participant T as 工具

    C->>S: POST /chat
    S->>R: 读历史、开始 Run
    R-->>S: 进度事件
    S-->>C: SSE 推进度
    R->>T: 带 RequestContext 调工具
    T-->>R: 工具结果
    C->>S: POST /cancel
    S->>R: 取消 Run(写库 + abort)

再记住三件与 ID、取消和历史消息有关的事:

  • 一个会话使用固定的 conversationId;用户每点击一次发送,都对应一个独立的 runId。
  • 取消请求必须携带明确的 runId,说明要停止哪一次任务。对于长时间运行的 Agent,不能只发送一句「停一下」,却没有明确的任务标识。
  • 给用户展示的历史消息,与 Run 执行期间产生的内部过程应区分存储。模型调用记录、工具调用参数、中间推理状态等,不应该未经筛选就全部当作普通聊天消息展示。

Snipaste_2026-10-27_16-01-42.png

鉴权、多租户与凭证

身份校验至少要分三层做,不能只在请求入口验证一次。原因很简单:入口验证的是「谁发起了请求」,工具执行时还需要验证「这个用户有没有权限执行当前操作」。

层主要职责
网关 / 会话服务验证登录态是否有效,构建 RequestContext,必要时进行限流
Agent Runtime(编排执行)根据授权范围决定哪些工具可以被调用,并将可信的 RequestContext 传给真正执行工具的代码
工具实现根据具体业务规则再次验证「这个用户能不能做这件事」;对于写操作,记录操作人、操作时间和操作结果

常见事故是:入口已经验证过登录态,工具里却直接使用模型参数中的 userId 来鉴权。模型一旦受到提示注入等因素影响,生成了另一个用户的 ID,就可能让工具误以为自己在替另一个用户执行操作。

因此,工具参数中的用户身份不能作为鉴权依据,只能使用由服务端可信认证流程构建的 RequestContext。

查库必带租户

租户 ID、用户 ID 应写入相关业务表,并且每次查询都要带上相应的隔离条件。不能只依赖前端「不要请求别人的会话 ID」这种约定。

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
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]
  )
}

这里还要注意一个业务边界:不是所有用户都只能访问自己创建的会话。 如果产品支持团队共享会话、项目协作或租户管理员查看数据,就应该在查询中显式加入对应的共享关系和授权规则,而不是简单地去掉 userId 条件。隔离规则应该由业务权限决定,不能靠某个 ID 是否存在来决定。

不只是聊天表,下面这些地方也必须使用同一套隔离原则,否则数据仍可能从其他入口泄漏:

  • 知识检索:向量检索和全文检索都应在服务端查询条件中加入租户及必要的访问范围,而不是查询完成后再过滤。
  • 长期记忆:读写记忆时,Key 或查询条件应包含正确的租户、用户或会话范围。
  • 工具调用:调用业务接口时,使用与当前请求对应的身份和授权范围。
  • 链路追踪与运营后台:追踪记录、审计日志和管理页面也要控制可见范围,避免普通用户通过日志或后台接口看到其他租户的数据。

为什么强调「先隔离,再查询」?

因为先查出所有相关数据、再在应用层过滤,并不能保证无权限的数据从未被接触。这些数据可能已经进入排序过程、缓存、日志,甚至模型上下文。对于企业知识库、内部运维助手等场景,这可能直接演变为跨租户数据泄漏。

管理员确实需要跨租户排查问题时,应走单独的管理接口和授权流程,按需开放权限,并记录操作人、查询原因和访问范围,不要直接复用普通聊天接口。

另外,用户注销或租户停用后,也要有明确的数据保留、删除和索引清理策略。会话、记忆、上传文件及检索索引都需要纳入管理,并能够核验清理结果。

Snipaste_2026-10-27_16-01-54.png

各种密钥不要进 Prompt

密钥和访问凭证只应该由服务端管理,不要写进发给模型的 Prompt,也不要写进会持久化的聊天正文或消息历史。

凭证类型存放与使用方式
调用大模型的 API Key放在密钥管理系统或受保护的服务端配置中,仅授权给需要调用模型的服务
用户访问凭证由服务端安全持有,并根据业务需要调用或换取访问业务接口的凭证;不要塞进聊天记录
租户自己的插件密钥按租户隔离并加密存储,真正调用插件时再由服务端解密使用
调用内部业务系统的身份凭证服务端根据 RequestContext 和授权策略获取合适的凭证,并在工具侧执行必要的权限校验

再强调一次: 模型工具参数里的 userId、tenantId、roles 等字段,不能直接作为可信的身份和授权依据。工具应忽略这些字段在鉴权上的作用,只认服务端构建的 RequestContext 和正式的授权结果。

还有一个细节值得注意:即使模型本身没有恶意,也可能因为用户输入、提示注入或上下文混淆而生成不可信的工具参数。因此,安全边界不能建立在「模型应该会填对」这个假设上。

起 Run:同一次点击只要一个任务

用户点击发送时,请求体至少要带上三个信息:会话 ID、用户原话,以及一个幂等键(Idempotency Key,也可以理解为请求去重键) 。

这个键由客户端在用户发起本次发送时生成。浏览器或 App 都可以生成,但同一次发送发生网络重试时,必须继续使用原来的键。不能每次重试都生成一个新键,否则服务端无法判断这些请求其实属于同一次操作。

例如,用户点击发送后,网络请求已经到达服务端,但响应在返回途中丢失。前端以为请求失败,又发送了一次。如果服务端没有幂等机制,就可能启动第二个 Agent 循环,导致模型重复调用,工具甚至重复执行真实业务操作。

type StartChatInput = {
  conversationId: string
  idempotencyKey: string // 请求键;短时间内相同则返回同一条 Run
  message: string        // 用户本轮输入
}

这里的重点不是简单地判断「两条消息是不是一样」,而是判断「两次请求是不是同一次业务操作」。用户完全可能有意连续发送两条相同的消息,所以不应该只靠消息文本去重。

短时间内,同一租户下的同一个请求键必须对应同一条 Run。服务端还应校验请求键与原始请求的关联:如果同一个键被拿来提交不同的会话或不同的消息,应拒绝请求或返回明确的冲突错误,而不是把新内容悄悄绑定到旧任务上。

Agent 可能运行很久,因此不要让接收发送请求的 HTTP 请求一直等待模型和工具全部执行完毕。更合理的方式是拆成两个阶段:

  1. 先登记任务:在数据库中创建一条 Run,并通过唯一约束防止同一个请求键重复创建。插入成功代表创建了新任务;如果发现请求键已存在,就返回已有的 runId,而不是再次启动 Agent。
  2. 再交给后台执行:将 runId 交给后台队列或任务调度机制,由 Worker 调用模型和工具。接收请求的一侧负责返回任务信息,并通过 SSE 等方式向前端推送进度。
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
);
// 步骤 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 已经写进数据库,但服务恰好在 queue.publish() 之前崩溃,这条 Run 就可能一直停留在 queued 状态。反过来,如果消息重复投递,也可能导致多个 Worker 同时尝试执行同一条 Run。

生产环境通常还需要配套机制,例如:

  • 通过事务发件箱(Transactional Outbox)等方式,协调数据库写入与队列消息发布。
  • 让 Worker 通过原子状态更新或租约机制领取任务,避免多个 Worker 同时执行同一条 Run。
  • 对超时未领取、执行中断或 Worker 崩溃的任务,提供可恢复的调度与排查机制。

这也是为什么不能只写一个 insert 加一个 publish,就认为任务调度已经可靠了。

超时怎么分层(建议):

  • 模型调用超时:根据任务执行情况,将 Run 标记为失败或部分完成,并通过 SSE 向前端发送明确的错误事件。若业务支持恢复,也可以进入相应的可恢复状态。
  • 工具调用超时:由实际执行工具的那一层判断是否适合重试,或把失败结果交给 Agent 决定下一步。对于重启服务、扣款、发通知等有副作用的操作,不能简单地认为重试一定安全。

尤其要区分两个概念:请求幂等,不等于工具执行幂等。 即使同一个请求只创建了一条 Run,Worker 崩溃后重新执行工具时,仍可能发生重复操作。因此,对于有副作用的工具,还需要根据业务设计独立的幂等键、执行记录或状态校验机制。

取消要真能停下模型和工具请求

SSE 或 HTTP 连接断开,只能说明前端暂时收不到进度,不代表用户明确请求取消,更不代表后台任务已经停止。

例如,用户在运维 Agent 中点击「取消」,页面上的加载动画消失了,但后台正在执行的模型请求和工具 HTTP 请求仍然可能继续运行。对于耗时较长的任务,这既会产生额外费用,也可能导致用户以为操作已经停止,实际业务却仍在发生变化。

因此,用户点击取消时,前端应调用明确的取消接口,不能只依赖关闭推流。

服务端至少要完成两件事:

  1. 将取消意图写入数据库,例如设置 cancel_requested = true,让其他 Worker 或后续执行步骤也能感知到。
  2. 尝试找到正在执行这条 Run 的任务,并触发中断信号(例如 abort()),打断支持中断的模型请求和工具 HTTP 请求。
async function cancelRun(runId: string, ctx: RequestContext) {
  // 1) 先持久化取消意图,让其他机器和后续步骤也能感知
  await db.markCancelRequested(runId, ctx.tenantId)

  // 2) 尝试中断本机任务;跨机器执行还需要额外的通知机制
  const worker = workers.get(runId)
  worker?.abort()
}

这里需要特别注意:把 cancel_requested 写进数据库,只代表取消意图已经记录,不代表任务已经停止。

如果任务运行在另一台机器上,本机的 workers.get(runId) 就找不到它;如果工具已经把请求发送到外部系统,即使本地中断成功,外部操作也未必能够撤销。

因此,后台每一步都应同时检查数据库中的 cancel_requested 和当前调用的中断信号(例如 AbortSignal)。对于跨机器执行的任务,还需要通过队列通知、事件机制或 Worker 主动轮询等方式传递取消信号。

前端取消路径可以统一为 POST /runs/:id/cancel。服务端收到请求后,应先校验当前用户是否有权取消这条 Run,再记录取消意图并通知执行端。

对于状态管理,也要区分:

  • cancel_requested:用户已经提出取消请求。
  • cancelled:任务已确认停止,进入终态。
  • failed:任务因为错误而失败。
  • completed:任务正常完成。

不能在收到取消请求后,就直接向前端宣称任务已经取消成功。更稳妥的做法是先返回「取消请求已受理」,再根据 Worker 的实际状态更新 Run。

如果工具已经产生了不可逆的业务副作用,例如重启服务、发送邮件或提交订单,取消机制也不能把已经发生的操作变回未发生。此时需要明确告知用户任务执行到了哪一步,并根据业务设计补偿操作或人工处理流程。

任务可以交给后台队列运行,但这次任务执行到哪一步、进度如何推送、谁有权限查看和取消,仍然需要有明确的 Run 状态作为依据。不要另外建立一张缺少租户字段的任务表,否则很容易出现任务状态、权限和取消操作彼此对不上的情况。

Snipaste_2026-10-27_16-02-01.png

对外接口与排查

对外最少准备以下几个接口,路径可以按项目规范调整,但职责要明确:

  • 拉取历史消息: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 业务事件更新页面即可,不要通过解析不完整的内部日志来猜测任务进度。

另外,SSE 主要负责实时推送,不应该成为任务状态的唯一来源。浏览器刷新、网络切换或移动端进入后台,都可能导致连接中断。客户端重新连接时,应能根据 runId 查询当前状态,并从服务端恢复必要的进度信息。

出问题先查(也是最常见的踩坑点):

  1. 换手机或浏览器后,无法继续刚才的对话:检查会话、消息和 Run 是否持久化到了数据库;如果只保存在内存中,服务重启或扩容后就可能丢失。
  2. 工具返回 401,或者工具错误地使用了其他人的身份:检查 RequestContext 是否传到了工具层、访问凭证是否过期,以及代码是否误用了模型参数中的 userId 或 tenantId。
  3. 用户点了取消,调用费用仍在产生:检查是否只关闭了 SSE;取消意图是否写入数据库;模型和工具请求是否真正支持并接收了中断信号。
  4. 用户能看到其他租户或其他用户的会话:检查查询是否只按 conversationId 匹配;租户、用户及共享权限条件是否完整。
  5. 连续点击发送后启动了两个任务:检查创建 Run 时是否使用客户端生成的幂等键;相同请求键是否只对应一条 Run;Worker 是否存在并发领取同一任务的问题。
  6. Run 一直停留在排队状态:检查数据库写入成功后,队列消息是否发布成功;Worker 是否正常领取任务;是否存在任务已登记但未成功投递的情况。
  7. 工具操作重复执行:除了检查 Run 的幂等创建,还要检查工具自身是否具备幂等保护,尤其是涉及订单、通知、配置修改和运维操作的工具。

小结

会话服务要真正立住,核心是三件事:身份从哪里来、一次发送如何起停、数据如何隔离。

  1. 身份只认可信的 RequestContext 。 网关或会话服务完成身份认证后,将上下文传递到整个调用链路。工具不能使用模型参数里的 userId 或 tenantId 作为鉴权依据,还要按照具体业务规则验证操作权限。密钥和访问凭证只由服务端管理,不能进入 Prompt 或聊天记录。
  2. 一次点击对应一条 Run,取消也必须有明确结果。 客户端通过幂等键标识同一次发送,服务端负责防止重复创建任务,并将任务持久化后交给后台执行。取消时,既要记录取消意图,也要通知执行端并中断支持取消的请求。关闭 SSE 只是停止接收进度,不等于任务已经停止。
  3. 租户隔离必须发生在数据访问过程中,而不是查询之后。 会话、知识检索、长期记忆和后台任务都要应用正确的隔离规则。不要先把所有数据查出来,再在应用层过滤;同时,也不要忽略 Worker 重试、跨机器取消和工具副作用这些容易被遗漏的工程细节。

最后,想和大家交流两个实际问题:

  • 问题一: 你们在 Agent 项目中,是把会话和 Run 状态统一交给业务服务管理,还是更多依赖 Agent 框架自带的 Checkpoint?
  • 问题二: 对于执行时间很长、还会调用外部系统的 Agent,你们是怎么实现真正的取消机制的?有没有遇到过前端显示取消成功,但后台仍在执行的情况?

欢迎在评论区分享你的实践、踩过的坑,或者直接提出疑问。尤其欢迎补充真实的故障案例。

希望这篇文章能给正在做 Agent 落地的朋友一些参考。