Skip to content

智能体的身份链与工具安全 ​

定位:《ReAct 与 LangGraph 工程实践》的姊妹篇。智能体比普通接口多了一个"会用工具的模型",安全模型随之改变:模型是不可信组件。它会幻觉、会被提示注入。素材来自 ai-service 生产代码(app/core/security.py、app/api/open_agent.py、app/tools/__init__.py,下文路径均相对 app/)。

一、身份链走读:从网关到工具的五步 ​

用户带 JWT ──▶ ① 网关验签放行 ──▶ ② ai-service 二次验签(parse_member_id)
              ──▶ ③ ContextVar 写入 memberId ──▶ ④ ReAct 图执行(模型无感知身份)
              ──▶ ⑤ 工具读 ContextVar,以该身份查数据

1)二次验签:parse_member_id(core/security.py) ​

网关验过 JWT,但不向下游透传"用户是谁",而智能体工具恰恰需要 memberId 才能收口查询。所以服务自己再验一次——与网关共用同一 HS256 密钥:

python
payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])  # 验签+查过期+解析
sub = payload.get("sub")            # Java 侧 JwtUtil.generateToken 写入的用户 ID
...
return int(sub)                     # sub 在 Java 侧是 String.valueOf(memberId),这里转回 int

三个细节:algorithms=["HS256"] 显式白名单(防算法混淆攻击,不接受 token 自称的 alg)。exp 过期由 PyJWT 自动拒绝。sub 的类型转换是对齐 Java 签发端的跨语言契约——JWT 只约定结构不约定类型,两端必须口头对齐。

api/open_agent.py 的 _member_or_none 剥掉 Bearer 前缀后调用它。任何 InvalidToken/缺头都归一为 None,端点层直接拒绝(ask 返回业务失败、chat 返回 401 的 SSE 错误帧)。身份解析失败没有降级路径,fail-closed。

2)ContextVar 注入:身份在代码层流动(tools/init.py + agents/assistant.py) ​

身份的载体与读取口都定义在 tools/__init__.py:

python
member_ctx: ContextVar[int] = ContextVar("member_id", default=0)   # 请求级上下文变量

def current_member_id() -> int:
    member_id = member_ctx.get()
    if not member_id:                       # fail-closed:没有身份就不查数据
        raise ValueError("缺少用户身份(memberId),拒绝执行查询")
    return member_id

而写入发生在图的执行入口(agents/assistant.py 的 run/stream),在智能体整个执行生命周期内持有:

python
token = member_ctx.set(member_id)
try:
    result = await graph.ainvoke(state, config)
finally:
    member_ctx.reset(token)          # 请求结束恢复,绝不泄漏给下一个请求

工具的每一次内网查询都被钉在这个身份上:

python
r = _run(internal_get("star-site-biz-health", "/internal/health/reports",
                      {"memberId": member_id}))     # memberId 来自 ContextVar,不来自模型

try-finally + reset 是 ContextVar 的标准收尾。并发下 A 请求 set 的值若不 reset,可能串进复用同一上下文的后续任务。上下文变量用完必须归还,和锁一个纪律。

3)"模型无法伪造身份"的原理 ​

关键在** memberId 根本不在模型的可见范围里**。不在 prompt、不在工具参数、不在对话历史——它在 ContextVar 里,只有代码能读写。模型能决定的只有"调哪个工具、传哪个 report_id",而每个工具的每次查询都被钉死在 params = {"memberId": current_member_id()} 上。哪怕用户输入"帮我查 memberId=1 的全家数据",模型也没有任何途径把这个 1 变成查询条件。

对照 Web 安全的思维迁移:

Web 时代智能体时代共同原则
服务端 session,用户不能自报 uidmemberId 走 ContextVar,不进 prompt身份只来自可信信道(验签),不来自用户输入
CSRF:操作绑定会话而非参数查询绑定 ContextVar 而非模型参数权限凭据与请求参数分离
越权测试:改 id 试访问越权测试:让模型查别人的 id横向越权的测试面不变

一句话:权限相关的参数永远从可信上下文注入,不经过模型。模型是新加入的"不可信中间人",把它当作用户输入的放大器来设防。

二、工具安全三原则 ​

1)最小权限 ​

工具集只有 4 个只读查询(成员/报告/明细/资产负债),没有写操作。且每个查询都按 memberId 收口到"自己的家庭"。给智能体的能力上限就是事故的最大爆炸半径——先问"这个工具被滥用会怎样"再决定要不要给。查询失败的口子也被收窄为可观察文本:

python
if r.get("code") != 0:                       # R 信封检查:业务失败作为文本返回
    return f"查询家庭成员失败:{r.get('message')}"

异常细节不裸露给模型和用户,只回业务语义。

2)输入校验 ​

工具参数全部强类型(report_id: int、family_member_id: Optional[int]),模型填不进畸形值;HTTP 入口再由 Pydantic(AgentChatRequest.message: str 非空校验)挡一层。校验发生在边界,而不是在模型推理里"提醒它别乱传"——约束靠类型系统,不靠提示词的自觉。

3)输出截断 ​

list_recent_reports 只给"最近 20 份摘要、不含指标明细",要看明细必须再调 get_report_detail。既控制上下文长度(省 token、防超窗),也控制单次信息暴露面。工具输出是"喂给模型再转述给人"的中间产物,按需分页比一次倒完安全也便宜。同理,资产负债表里档案服务不可用时输出"未知(档案服务不可用)"而不是错误堆栈。降级信息也是输出设计的一部分。

三、端点 URL 固化白名单 ​

工具经服务发现调内网端点(core/discovery.py 的 internal_get),调用形态是:

python
internal_get("star-site-biz-health", f"/internal/health/reports/{report_id}", {"memberId": member_id})

服务名、路径模板都是代码里写死的常量,模型可控的只有路径里的 report_id(且被 int 类型钉死)。即:无论模型被怎么注入,它都构造不出一个指向任意主机的请求。这就是 URL 固化白名单,SSRF 防御在智能体场景的直接对应物。

对照教训:voice-utils 项目曾把用户可控的 URL 直接作为出站请求目标,险些形成 SSRF(内网探测/云元数据读取)。修复方案同样是白名单——只允许命中固化的目标清单。两件事是同一条红线:出站目标永远不由输入决定,输入只能在固化的目标上选择参数。智能体场景只是把"用户输入"升级成了"模型决策",而模型决策必须被当成不可信输入对待。

四、追问链 ​

  1. 网关已经验过签,为什么还要二次验签? 网关不透传身份,服务拿不到 memberId。更本质的是零信任——内网调用不应因为"来自网关后面"就免验。同密钥 HS256 的成本只是一次本地 HMAC 校验,没有网络开销。
  2. 为什么用 ContextVar 而不是全局变量/参数透传? 全局变量在异步并发下串号(A 请求的身份覆盖 B 请求)。参数透传要把 memberId 层层穿过 agent → ToolNode → 工具签名,污染 LangGraph 的通用接口。工具参数会暴露给模型,恰好是要避免的。ContextVar 是异步原生的请求级隔离,set/reset 配 try-finally 就是标准用法。
  3. 内部端点不带 JWT,安全吗? 靠网络隔离 + memberId 收口双保险。/internal/** 只在内网可达(不经网关暴露),且每个查询都强制带 memberId 归属校验。拿到内网访问权也查不了别人的数据。这是"边界信任"退化为"数据面收口"的务实做法,与二次验签的零信任互补。
  4. 提示注入能骗到什么? 能骗模型调错工具、传错 report_id、说出不该说的语气。骗不到身份——查谁由 ContextVar 决定。所以防注入有两个措施。一是损失面控制(只读工具 + 输出截断,注入了也无利可图)。二是指令层对抗(系统提示压幻觉,工具输出只当数据不当指令)。
  5. memberId 泄漏给模型会怎样? 若 memberId 进了 prompt,用户可说"用 memberId=1 查"诱导模型填参越权。前提是工具接受 memberId 参数。本工程工具签名里根本没有此参数。接口设计直接消灭攻击面,比提示词约束可靠得多。

五、面试官视角小结 ​

面试官在"智能体安全"题里打量的三件事:

  • 把模型当不可信组件:能说出"模型=用户输入的放大器"。权限凭据不进 prompt、不进工具参数,从接口设计上消灭伪造面,而不是靠提示词央求模型。
  • 身份链讲得完整:验签(算法白名单、sub 契约)→ ContextVar 注入(并发隔离、fail-closed)→ 工具收口(memberId 钉死查询)。一条线无断点,并能类比 session/CSRF。
  • 工具权限有边界感:最小权限(只读)、强类型校验、输出截断、URL 固化白名单。每条都能指出对应的传统安全原则(最小权限原则/SSRF 白名单),说明是安全思维的迁移而非新发明。

一句话总结:身份走可信信道(JWT 验签→ContextVar→工具收口),模型只决策不持权。工具按最小权限+强类型+输出截断收边,出站目标固化白名单。把 Web 安全的老原则,钉进智能体的新接口。

2026-10 新增(P3 AI 栈 · 素材来自 ai-service 生产代码)。