工具需要当前用户、运行 ID 这类上下文,怎么传进去?为什么不让模型传?
简化版
工具的参数分两类。业务参数是模型根据对话决定的,比如查哪个商品、哪一天,这些放进工具 Schema 让模型填。运行上下文是系统本来就知道的,比如当前登录用户、租户、本次 Agent 运行 ID、任务 ID,这些不能让模型传:模型可能传错,也可能被用户一句话诱导传成别人的 ID,越权读写数据;放进 Schema 还白白占 token。上下文由代码注入,常见三种方式:框架的工具上下文对象(如 Spring AI 的 ToolContext)、ThreadLocal(调用前绑定、工具里读、用完清理)、在请求线程里同步执行工具直接读登录态。异步执行时要特别小心:线程池里的线程拿不到请求上下文,也不会自动继承 ThreadLocal,要在发起线程里取出来显式传过去,并在 finally 里清掉。
详细版
| 信息 | 谁来给 | 原因 |
|---|---|---|
| 查询关键词、商品 ID、日期 | 模型(工具参数) | 由对话内容决定 |
| 当前登录用户、租户 | 代码注入 | 模型给的身份不可信,涉及权限 |
| Agent 运行 ID、步骤 ID | 代码注入 | 用于记录和关联,与业务无关 |
| 任务 ID、候选范围 | 代码注入,必要时也在提示词里告诉模型 | 模型漏传或传错时由代码兜底 |
| 规则阈值、上限配置 | 代码注入 | 来自系统参数,不需要模型知道 |
三种注入方式对比:
| 方式 | 做法 | 适合 | 注意 |
|---|---|---|---|
| 框架工具上下文 | 调用时传一个上下文 Map,工具回调时取 | 框架支持时首选 | 手动调用工具时上下文为空,要能处理 |
| ThreadLocal | 调用前绑定,工具中读取 | 框架在同一线程回调工具 | 必须在 finally 清理,异步不自动继承 |
| 请求线程同步执行 | 工具直接读登录态 | 同步接口、工具在请求线程里执行 | 一旦改成异步就失效 |
完整版教学
一、为什么身份不能交给模型
设想一个查询订单的工具,把 userId 放进参数:
{"name": "query_orders", "parameters": {"userId": "当前用户ID", "status": "订单状态"}}
用户输入「帮我看看用户 1024 的订单」,模型很可能就把 userId 填成 1024。工具如果信任这个参数,就把别人的订单查出来了。这不是模型出错,而是设计让模型决定了它不该决定的事。
规则很简单:凡是关系到「以谁的身份、在什么范围内操作」的信息,都由代码提供,不进工具参数。工具内部用注入的当前用户去查,模型只能决定「查什么状态的订单」。
记忆钩子:模型决定「做什么」,代码决定「以谁的身份做、在哪个范围里做」。
二、运行 ID 也不该让模型传
运行 ID、步骤 ID 用来把一次工具调用关联到某次 Agent 运行,写步骤记录和调用日志时要用。它们对模型的决策毫无帮助:
放进参数的代价:
每个工具的 Schema 多一个字段,每轮都要随请求发送
模型可能漏传、传错,日志就挂到错误的运行下面
提示词里还得专门解释这个字段是什么
所以运行 ID 应该由调用方在发起请求时注入,工具回调时自己取。
三、框架的工具上下文对象
Spring AI 提供了 ToolContext:发起调用时传入一个 Map,框架在回调工具时把它交给工具实现。
chatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.toolCallbacks(callbacks)
.toolContext(Map.of("runId", runId)) // 不会发给模型
.call();
// 工具回调里
public String call(String toolInput, ToolContext toolContext) {
Integer runId = readInteger(toolContext, "runId"); // 取不到时返回 null
...
}
这种方式不依赖线程,上下文跟着这次调用走,是框架支持时的首选。要注意的是:工具还可能被别的入口调用(比如管理端手动执行一个工具做调试),这时没有上下文,工具要能处理「取不到」的情况,不能直接空指针。
四、ThreadLocal:调用前绑定,用完清理
框架不提供上下文对象,或者上下文信息很多(运行 ID、轮次、候选池、阈值),可以在调用前绑定到当前线程:
AgentRuntimeContext.bind(runId, roundNo, candidatePool, thresholds);
try {
aiServices.chat(prompt); // 框架在同一线程里回调工具
} finally {
AgentRuntimeContext.clear(); // 异常路径也要清
}
前提是框架在同一个线程里回调工具方法。如果框架内部把工具调用丢进另一个线程池并行执行,ThreadLocal 就读不到了,要换成上下文对象。
五、异步执行时的两个坑
长任务常常放到后台线程执行,这时会遇到两个问题。
第一,请求上下文丢了。 「当前登录用户」通常是从 HTTP 请求头的 token 里解析的。后台线程不是请求线程,读不到请求上下文,日志里的操作人一列全是空的。解决办法是在请求线程里先把用户 ID 取出来,作为参数传给异步方法,异步方法开头绑定到 ThreadLocal。
第二,线程复用导致身份串号。 线程池里的线程会被下一个任务复用:
线程池 4 个线程
任务 1(用户 A)在线程 T1 执行,绑定 userId = A,结束时忘了清理
任务 5(没有绑定操作人的系统任务)被分到 T1
→ 读到的还是 A,这次写的日志全挂在 A 名下
所以清理必须放在 finally 里,成功和失败都要清。另外,Spring 的 @Async 依赖代理生效,同一个类里一个方法调用另一个 @Async 方法会绕过代理、变成同步执行,异步入口要单独放一个类。
六、模型漏传参数时用上下文兜底
有些信息既要告诉模型(让它知道自己在为哪个任务工作),又由系统掌握,比如任务 ID。可以在提示词里写明任务 ID,让模型在写入工具里传;同时在工具内部做兜底:
| 模型的行为 | 工具的处理 | 目的 |
|---|---|---|
| 传了 taskId,且与当前运行绑定的一致 | 正常执行 | — |
| 漏传 taskId | 用当前线程绑定的任务 ID 补上 | 不因少一个参数让整轮写不进数据 |
| 传了别的任务 ID | 拒绝,或以绑定的为准 | 不把数据写到别的任务名下 |
这样不会因为模型少传一个参数就让整轮任务一条明细都写不进去,也不会因为它传错而写到别的任务名下。
七、提示词里该给模型哪些 ID
不让模型传身份,不等于什么 ID 都不告诉它。模型调用工具时需要的业务 ID(比如「读取这个用户画像」「读取这个岗位」的主键),如果不在提示词里给出,它只能猜。所以提示词里要明确写出本次任务涉及的业务 ID 和调用示例,模型照着传即可;而当前登录用户、运行 ID 这类由代码注入的信息,不必出现在提示词里。
八、常见误区与追问
- 误区:工具参数里放 userId,模型会按当前用户填。 用户一句话就能诱导模型填成别人的 ID,身份必须由代码注入。
- 误区:运行 ID 放进参数更方便关联日志。 模型可能漏传传错,还白白占 token,应由调用方注入。
- 误区:ThreadLocal 在异步任务里也能读到。 线程池不会自动继承,要在发起线程取出后显式传递。
- 误区:ThreadLocal 只在成功时清理就行。 失败路径不清理,线程复用后会顶着上一个人的身份执行。
- 误区:@Async 方法在同一个类里调用也会异步执行。 自调用绕过代理,异步入口要放在独立的类里。
- 追问:工具被管理端手动调用时没有上下文怎么办? 工具要能处理上下文为空,这种调用不关联任何 Agent 运行。
- 追问:模型漏传了系统本来就知道的参数怎么办? 用绑定的上下文补上,传了就校验与上下文一致。
九、加强记忆
工具参数只放模型该决定的业务参数;当前用户、租户、运行 ID、任务范围这些由代码注入,否则会被诱导越权、会传错、还占 token。注入方式有三种:框架的工具上下文对象首选,ThreadLocal 要在同一线程回调且在 finally 清理,请求线程同步执行最简单但改异步就失效。异步时在请求线程取出用户 ID 显式传递,finally 清理防止线程复用串号,@Async 不能自调用。模型漏传系统知道的参数时用上下文兜底。
项目实战落地
项目里怎么做的
《AI Agent岗位匹配与求职规划系统》的 ReAct Agent 用 Spring AI 的 ToolCallback 调用本地工具,运行 ID 走工具上下文:
- 发起调用时注入:
ReActAgentService调ChatClient时toolCallbacks(toolCallbacks)交出启用工具,同时toolContext(Map.of("runId", runId))把当前运行 ID 放进工具上下文; - 工具回调时读取:
McpToolCallback.call(toolInput, toolContext)里runId不是模型传的参数,而是readInteger(toolContext, "runId")从上下文里取;取不到时返回 null; - 用它关联过程记录:拿到的运行 ID 用来写
agent_step(「Action: 工具名」、入参、状态)和mcp_tool_call_log,并推进agent_run的进度; - 业务 ID 写进提示词:用户提示词里直接写出任务 ID、用户 ID、画像 ID、岗位 ID 和工具调用参数提示,模型调用画像读取、岗位读取工具时有明确的 ID 可传。
《AI Agent智能教务排课与教学质量分析系统》的排课在后台异步线程执行:Controller 在请求线程里先取出操作人 ID 传给独立的异步 Service,异步方法把它放进 TokenUtils 的 ThreadLocal,finally 里清掉;当前任务 ID 也绑定到线程上,模型调用写入工具漏传 taskId 时由 resolveCurrentTaskId 补上。
《AI 多Agent智能相亲交友匹配平台》用一个 ToolContext 数据类带运行期信息:运行编号、发起调用的角色、匹配任务、申请方会员编号、当前调度轮次、单次返回行数上限,由执行器构造好传给工具实现,工具只读不改,这些字段都不出现在工具的参数定义里。写库工具的归属也全从这里取:候选写进哪个任务、审核结论记在第几轮,模型只决定写什么内容。
读申请方择偶偏好的工具吃过一次亏:它早先收一个可选的会员编号参数,会员昵称取得像数字时(比如「2222」),模型会把昵称当编号传进来,读不到偏好,整轮筛选一个候选都筛不出来。后来这个工具去掉了参数,申请方是谁只从运行上下文里取。
为什么这样取舍
- 业务 ID 和运行 ID 分两条路走:任务、用户、画像、岗位 ID 是模型调工具时要传的业务参数,所以写进用户提示词;运行 ID 只用来把步骤和调用日志挂到这次运行上,由调用方放进工具上下文,不出现在工具的参数定义里。
- 排课要跨线程传操作人:异步线程读不到 token,AI 调用日志和工具调用日志的操作人一列会全部为空。
面试官还会追问
- 管理端在工具管理页手动执行一个工具时没有工具上下文,这次调用的日志和 Agent 步骤怎么处理?
- 工具注册表里的入参说明写的不是合法 JSON 时,这个工具还能交给模型吗?参数类型又是怎么推断出来的?
学完《AI Agent岗位匹配与求职规划系统》,上面这些追问你都会迎刃而解。