1. 代理时代的信任危机:传统安全边界为什么全面失效
1.1 一次工具凭证泄漏事故的完整复盘
我接手过一个LangGraph代理项目,功能不复杂:把内部文档检索、外部工具调用和自然语言问答串在一起。上线前我做了所有“看起来正确”的事——在网关限制来源IP、把外部服务的密钥写进环境变量、给后端接口加了一层简单的API Key。结果第三周就出事了。有人通过一段精心构造的提示语,诱导代理反复调用某个查询工具,最终把带权限的凭证拼进返回内容里带了出来。
这次事故暴露的远不止“密钥保管不善”这么简单。密钥泄漏只是结果,真正的根子是信任模型出了问题。我以为请求只要从可信网络进来,代理就会按预设的规则执行;我以为工具凭证只要放在环境变量里,就不会被模型的输入输出干扰;我以为权限在登录时分配一次就够了,后面所有操作都可以沿用同一份授权。这三个“以为”,在AI代理面前全部不成立。
代理是自主行动的代码。它读一段自然语言,自行决定调用哪些工具、按什么顺序调用、把哪个工具的输出拼进下一步推理。这意味着每一个工具调用点都是一个真实的出口,中间任何一个环节被诱导,整条调用链就会变质。传统安全模型把精力放在“守住边界”,但代理本身就是边界内的主动方,它会自己伸手到边界外面去。
1.2 AI代理彻底打破了三条默认信任假设
第一,网络层隔离不再可信。过去我们在API前面放一层网关,白名单一拉,感觉世界就安全了。但代理拿到你的请求后,会自己发起网络调用、执行工具函数、访问外部服务。请求是否来自可信IP,根本不影响它后续的行为。真正该问的问题是:这个代理以谁的身份、带着什么权限、在什么上下文里执行这些动作。
第二,静态密钥不再可控。环境变量里的长期密钥,一旦被模型输出带出来、被日志记录下来、被调试工具翻出来,就是永久泄漏,而最短的处置周期也是“换一个再放到环境变量里”。代理的每一次工具调用都面临被诱导、被注入的可能,静态的、长期有效的凭证完全不适合这种动态场景。
第三,角色权限一次分配不再够用。用户登录拿到的角色,往往代表一个宽泛的权限集合。但代理的一次执行可能是“读取文档A”“调用外部搜索”“更新记录B”,这几种操作的敏感度完全不同。全量授权意味着模型一旦被诱导,就有能力触达它本不该触达的资源。权限应该绑定到具体的执行上下文里,而不是绑定到一个宽泛的角色标签上。
1.3 零信任的真实含义:把验证变成执行规则
零信任不是一句“永不信任、始终验证”的口号。落到代码层面,它意味着三件事:每个请求都必须显式证明自己的身份;每次授权都必须绑定到当前的最小上下文;每个敏感动作都必须有独立的校验点。
用生活化的方式理解,传统安全像是小区物业——进了大门,楼道里就默认是邻居。零信任则像是每进一间房门都要出示证件,而且这个证件只对当前那间房有效。对AI代理来说,这个比喻格外贴切,因为代理天生会自主地在不同“房间”之间穿行,一个房间里被骗到的东西,很可能被带进下一个房间使用。
1.4 LangGraph为什么是落地零信任最合适的载体
LangGraph这类代理编排框架,天然把执行流程拆成了节点和边,再加上持久化的线程上下文。这个结构对安全来说是巨大的优势:鉴权不再是在整个应用外面包一层壳,而是可以下沉到图执行的每一个节点;一次多轮对话的完整上下文也可以被固化成可审查的线程数据。
在LangGraph里,代理的工作流可以被精确控制到“哪一步该谁执行、执行时拥有什么权限、做完之后留下什么记录”。节点是执行的最小单元,线程是授权的最小上下文,工具调用是安全的最细出口。这三层结构恰好对应零信任要求的身份、上下文和最小权限,不是每个AI框架都能这么整齐地提供对应物。
2. 双身份模型:用户身份与代理身份必须彻底分离
2.1 第一层认证:用户与代理之间的身份确认
最常见的错误做法,是把用户token直接传给代理,然后让代理拿着用户的身份去调用所有工具。表面上省事,实际在安全上是个大坑。用户token往往带着宽泛的权限范围,代理的每次工具调用都会继承这组权限,等于给模型的所有自主行为都开了绿灯。而且,用户token的失效策略通常和代理的长任务执行不匹配,一个需要长时间运行的复杂任务,很可能在中间某个节点被突然失效的token打断。
正确做法是,用户身份只用来回答一个问题:“这个请求是谁发起的”。用户在进入代理时完成一次身份认证,拿到一个明确标识身份与授权范围的短期凭证。这个凭证只负责建立会话,不被透传到下游工具调用。用户的权限范围,在进入代理时被翻译成一份“代理可以在这个线程里做什么”的授权清单,用这份清单去约束代理后面的行为。
2.2 第二层认证:代理与工具之间的服务身份确认
代理调用工具时,它自己也需要一个身份。这是很多人容易漏掉的层次。工具方需要知道“谁在调用我”,这个“谁”不是最终用户,而是那个正在自主执行的代理进程。所以要在代理与外部服务之间建立独立的身份机制:服务身份、调用范围、短时凭证,三者缺一不可。
举个例子,代理要调用一个文件导出服务。服务端关心的是:这个调用是否来自可信的代理、代理是否被允许导出这批数据、这次调用可以持续多长时间。这些信息和“哪个用户发起的请求”已经是两回事了。用户身份回答业务层面的“谁”,代理身份回答系统层面的“谁”,混淆二者会导致权限失控。
2.3 身份载体的选型对比与取舍
实现这两层认证,常见的身份载体无非JWT、mTLS和动态短时令牌。我做过一轮对比,结果如下:
| 载体 | 适合场景 | 优点 | 局限性 |
|---|---|---|---|
| JWT | 用户与代理之间、服务间API认证 | 无状态、多服务共享验签、携带授权声明 | 签发后不能主动失效,需要配合吊销检查 |
| mTLS | 代理与内部加密服务之间 | 双向证书认证、链路加密、网络层身份 | 证书管理复杂、不适合对外公开服务 |
| 动态短时令牌 | 工具调用、高敏感操作 | 生命周期极短、泄漏影响面小 | 需要密钥签发服务、有额外开销 |
实际项目里我的选择是:用户与代理之间用JWT,代理与工具之间用动态短时令牌,内部核心链路的服务间调用视情况叠加mTLS。JWT的过期时间必须短,双token机制是标配。动态短时令牌的TTL控制在秒级到分钟级,保证即使凭证被带出,也很快失效。
2.4 线程级授权上下文:零信任的最小授权单元
LangGraph的线程概念在这里起到关键作用。一次会话可以有多个线程并行执行,每个线程代表一个独立的执行上下文。零信任要求“每次访问都验证”,那么线程就是最合适的授权载体——每个线程绑定独立的授权范围,线程内所有节点和工具调用都共享这份范围,但不能超越它。
比如一个用户同时发起“查询财报数据”和“发送邮件通知”两个任务,它们落在不同线程里。前一个线程只能读取数据,后一个线程才有发送通知的权限。如果两个线程用一个全局权限,代理被诱导时就有了跨任务操作的空间。线程级授权把这种风险隔离在最小范围。
3. LangGraph接入实战:从请求入口到图执行全程验证
3.1 总体架构:一切入口都是验证点
我采用的方式是FastAPI加LangServe包装LangGraph工作流,把认证逻辑放在同一个HTTP层。所有进入代理的请求,先过统一网关,再经过身份校验中间件,确认身份和授权上下文后,才允许创建或恢复LangGraph线程。
这里有一个容易走偏的地方:以为在网关做过一次认证就够了。零信任的“持续验证”要求在代理执行过程中也保持身份信息可追溯。我的做法是,在认证通过后把身份信息注入LangGraph的State,之后每个节点都可以读取当前执行身份,工具调用层还会再次基于这个身份去申请短时凭证。这样就形成了“入口认证一次、节点验证多次、工具调用动态鉴权”的链路。
3.2 用JWT中间件守住第一个入口
先看接口层怎么校验JWT。我用FastAPI的HTTPBearer依赖,统一处理Authorization头。
from fastapi import Depends, HTTPException from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials import jwt security = HTTPBearer(auto_error=False) def get_auth_context( credentials: HTTPAuthorizationCredentials = Depends(security), ) -> AuthContext: if credentials is None: raise HTTPException(status_code=401, detail="missing_token") try: payload = jwt.decode( credentials.credentials, JWT_PUBLIC_KEY, algorithms=["RS256"], options={"require": ["sub", "exp", "jti", "thread_id"]}, ) if is_token_revoked(payload["jti"]): raise HTTPException(status_code=401, detail="token_revoked") return AuthContext( user_id=payload["sub"], thread_id=payload["thread_id"], scopes=payload.get("scopes", []), jti=payload["jti"], ) except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="token_expired") except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="invalid_token")这段代码有几个关键点。第一,要求payload里必须有sub、exp、jti、thread_id四个字段;thread_id的作用是绑定线程,后面恢复LangGraph状态时会用到。第二,用jti做吊销检查,JWT签发后服务端无法主动让它失效,但可以在Redis里记录已吊销的jti,实现类似“登出”的能力。第三,验签用非对称密钥,签发端持有私钥,LangGraph服务端只保留公钥,这样即使应用被攻破,也无法伪造其他身份的token。
3.3 把身份注入LangGraph状态,贯穿全图执行
认证通过后,下一步是把身份信息放进LangGraph的State里。我在定义State时专门加了一个actor字段,用来携带认证上下文。之后每个节点在执行时,都可以从这个字段里读取当前身份和权限范围。
from typing import TypedDict, Annotated from langgraph.graph.message import add_messages class AgentState(TypedDict): messages: Annotated[list, add_messages] actor: AuthContext tool_calls: list def entry_node(state: AgentState) -> AgentState: # 在入口节点把认证信息固化进状态 return { "actor": get_current_auth_context(), "messages": state["messages"], }强调一下,身份信息必须在入口节点就固化,而不是在每个节点里重新获取。这样做有两个原因。一是LangGraph的图可能会被持久化、恢复、重放,如果身份信息是执行时才临时取的,恢复执行时可能已经拿不到原始的认证上下文。二是把身份固化进State后,所有下游节点只能基于这份身份执行,无法被其他数据覆盖或伪造。
3.4 Access Token过期刷新与代理重试的兼容设计
JWT的过期时间不能设太长,我一般把Access Token控制在15分钟内,Refresh Token控制在7天内。但这里有个和代理执行不兼容的问题:一个复杂的LangGraph任务可能执行超过15分钟,中途token过期会导致节点调用全部失败。
我的处理策略是分层解耦。用户请求进来时用Access Token建立线程,之后LangGraph内部节点不再依赖原始Access Token,而是依赖注入的授权上下文。工具层调用用动态短时令牌,和用户token完全隔离。这样用户token即使过期,也只会影响新的请求进入,不会中断已经在执行的图。线程的续期则由Refresh Token负责,线程结束时用Refresh Token重新换Access Token,实现长任务的无缝衔接。
4. 节点级权限控制与工具调用凭证管理
4.1 节点级鉴权:不是进了门就能一路绿灯
如果只做入口认证,权限控制还是太粗。LangGraph的节点是执行的基本单元,所以鉴权也应该下沉到节点级别。每个节点在执行前检查当前线程的授权上下文,看是否具备执行该节点的权限。
NODE_TO_SCOPE = { "read_document": ["document:read"], "write_document": ["document:write"], "send_notification": ["notification:send"], "delete_resource": ["resource:delete"], } def require_scope(node_name: str): def decorator(node_fn): def wrapper(state: AgentState): actor = state["actor"] required = NODE_TO_SCOPE[node_name] if not set(required).issubset(set(actor.scopes)): raise PermissionError(f"{node_name} is not allowed for this thread") return node_fn(state) return wrapper return decorator这里保留了一个我在项目中犯过的错:我把所有权限判断堆在入口,后来发现只要诱导代理发起一个不在预期内的节点路径,就能绕过细粒度控制。后来改成节点级检查,每个节点的作用域在编译前整理成清单,执行时做子集校验,才把漏洞补上。注意,节点级鉴权不是把入口鉴权删掉,而是加一道纵深防线。
4.2 工具调用的动态短时凭证:让每次调用都有独立身份
工具调用是整个链路上最敏感的部分,因为代理在这里真正触达外部世界。我给工具调用加了一层包装器,每次调用前从密钥中心申请一个短时令牌,令牌绑定当前线程身份和工具作用域。
def with_short_lived_token(handler, scope: str): def invoke(*args, **kwargs): token = vault.issue_token( subject=current_identity(), scope=scope, ttl_seconds=60, ) headers = {"Authorization": f"Bearer {token}"} try: return handler(*args, headers=headers, **kwargs) except TokenExpiredError: # 只允许一次重试,防止无限循环 token = vault.issue_token( subject=current_identity(), scope=scope, ttl_seconds=60, ) return handler(*args, headers={"Authorization": f"Bearer {token}"}, **kwargs) return invoke这个设计的核心价值是“攻击面收窄”。即使用户输入诱导代理把令牌带进输出,这个令牌也会在1分钟内失效,能造成的破坏远小于一个长期有效的环境变量密钥。同时,每个工具调用使用独立的令牌,审计时能精确到“某线程在某时刻调用了某工具”。
还有一件容易被忽略的事情:工具调用的日志和链路追踪必须做脱敏。我之前遇到过令牌被记录在trace里的情况,虽然凭据很快过期,但这种事一旦发生就要立刻整改。写入日志之前要把Authorization头和其他敏感字段全部清洗掉。
4.3 敏感操作的审批中断机制
还有一些操作风险更高,比如删除资源、发送外部通知、执行写操作。这些节点建议使用LangGraph的interrupt机制,在执行前暂停,等人工确认后才继续。我在图编译时显式声明了敏感节点的中断点:
graph = workflow.compile( checkpointer=checkpointer, interrupt_before=["delete_resource"], )这样代理执行到delete_resource节点前会自动暂停,等待人工审批通过才能继续。实际操作中,我会把待审批的操作推送到一个内部审批队列,展示给操作者的是“代理请求删除某资源,需要确认”,确认后恢复线程执行。这等于给最危险的操作加了一道物理闸门,而不是依赖模型的自我判断。
4.4 审计日志与出站调用管控
做完上面的改动,还有一个配套动作:让每一次执行都可追溯。我给每一个节点和工具调用都埋了审计点,记录线程ID、身份、动作、目标资源和时间戳。审计数据独立于业务日志存储,单独设置访问权限,让安全团队可以事后还原一次完整调用链。
出站调用管控也不能省。代理可能访问的外部域名,应该预先列清单,不在清单内的域名一律拒绝。这就是为什么我不能只靠模型“应该不会调用恶意地址”来判断安全,要有一个实际的出口防火墙来兜底。
5. 生产环境踩坑清单与排查速查表
5.1 token过期引发的全链路重试风暴
问题现象是某个长任务运行一段时间后,突然出现大量401错误,代理的多个分支并行重试,把日志系统都打满了。排查发现,根本原因是LangGraph在执行过程中有并行节点,多个分支同时持有同一份已经过期的token,各自重试时又对同一个上游服务发起请求。
解决办法分三步。第一,节点间不再共享用户token,改走注入的授权上下文,和原始token解耦。第二,工具调用全部使用短时令牌,且不依赖外部token的生命周期。第三,重试逻辑加上指数退避和随机抖动,避免一瞬间的并发重试风暴。
5.2 密钥出现在链路追踪里
另一次排查中,我在分布式追踪的span里看到了完整的API Key。原因是对外HTTP客户端的默认日志会记录请求头,而链路追踪库又把整个header快照传到了聚合端。这个问题的隐蔽性在于,开发环境日志量小,不容易暴露;生产环境流量一大,密钥就在追踪系统里裸奔。
处置方式是两个动作一起做:一是在生成HTTP客户端时显式声明需要脱敏的header字段,二是配置追踪系统的标签规则,对Authorization和Cookie这类字段一律抹掉。改造后做了一次全量历史日志扫描,确认没有其他敏感字段泄漏才放心。
5.3 线程级越权与上下文错绑
这个问题只在多用户共用部署时出现。LangGraph用thread_id恢复线程状态,如果只校验“thread_id存在”而没校验“thread_id属于当前用户”,A用户可以传入B用户的thread_id,从而接管对方的历史会话和授权上下文。
修复方法是在线程创建时,把actor信息写入线程元数据;恢复线程时,对比当前认证身份和线程绑定的身份是否一致。LangGraph的checkpointer保存了完整的线程状态,恢复时带上原始actor字段,可以有效防止上下文错绑和横向越权。
5.4 排查速查表
| 现象 | 可能原因 | 快速处置 |
|---|---|---|
| 大量401错误 | token过期、令牌生命周期不一致 | 分层解耦token,节点用授权上下文,工具用短时令牌 |
| 日志或trace中出现敏感密钥 | HTTP客户端记录header、追踪未脱敏 | 全局配置敏感字段抹除,扫描历史日志 |
| 用户能访问他人线程 | 恢复线程时未校验归属 | 线程元数据写入身份,恢复时校验actor一致 |
| 节点路径绕过权限 | 只做入口鉴权,节点无独立校验 | 下沉节点级鉴权,声明节点作用域 |
| 工具凭证被输出带出 | 静态密钥泄漏 | 改为短时令牌,限制单次调用生命周期 |
| 重试导致上游被打爆 | 无退避机制、并行重试 | 指数退避加随机抖动,限制单线程并发频率 |
| 敏感操作未经确认被执行 | 节点直接放行 | 用interrupt_before挂起敏感节点 |
5.5 一条适用性很高的经验清单
做完这套改造,我把心得归纳成几条可复用的经验:一是把所有“宽泛信任”换成“显式验证”,入口一次、节点一次、工具一次,每层都校验;二是密钥尽量短命,宁可多发几次,不要长期有效;三是审计从第一天就做,不要等出事后才补数据;四是权限最小化要落到线程而不是角色;五是把敏感操作的审批做成流程的一部分,而不是依赖模型自觉。这些经验不绑定某个具体框架,换到其他代理编排系统同样适用。
我在实际项目中的体会是,零信任落地更像一个持续逼近的过程,而不是一次部署就结束的项目。最初给LangGraph代理套上认证中间件时,我觉得已经够了;后来遇到工具凭证泄漏,发现只堵入口远远不够;再把凭证改成动态短时令牌、把鉴权下沉到节点后,才真正感觉到攻击面被压缩了。每一次改动都在回答同一个问题:当前这条调用链上,还有哪些环节是凭“默认可信”在运行。如果你也在做LangGraph代理,建议先别急着铺开做完整框架,挑一个最敏感的工具,画出它的调用路径,找出所有出口,然后从动态凭证这一步开始改。把最小的验证链路跑通,再逐步推广到全图——保护一个AI代理,本质上就是看好它伸向外部世界的每一条触手。