前言
在前一个阶段中,主要学习了:
-
Agent 和 Workflow 的区别;
-
Agent、Java 和人工之间的职责边界;
-
Tool 的粒度;
-
Tool Schema;
-
Tool Result。
这一阶段继续向下深入,重点解决一个问题:
模型选择了一个 Tool 之后,系统到底是怎么执行的?Agent 又是如何连续调用多个 Tool,并保证中断后能够继续、并发时不会执行旧指令的?
本文总结 Agent 学习的内容主要包括:
-
Tool Calling 的完整执行链路;
-
为什么 Tool Call 只是建议,不是命令;
-
Agent Loop 如何连续调用多个 Tool;
-
如何防止 Agent 死循环;
-
Agent State 应该保存什么;
-
State 与聊天记录、Memory 的区别;
-
并发请求下如何防止旧状态覆盖新状态;
-
如何保证 State 和真实业务操作的一致性。
一、Tool Calling 不等于模型直接执行 Java 方法
假设系统中存在一个 Java 方法:
public OrderInfo queryOrder(String orderId) {
return orderService.queryOrder(orderId);
}
用户说:
帮我查询订单 A10086。
很多初学者可能会认为,大模型会直接进入 Java 程序并调用:
queryOrder("A10086");
实际上,大模型本身不会执行 Java 方法。
完整链路通常是:
用户输入
↓
系统把 Tool 名称、描述和参数 Schema 发送给模型
↓
模型生成 Tool Call
↓
Java 解析 Tool Call
↓
Java 校验 Tool、参数、权限和业务条件
↓
Java 真正执行方法
↓
把 Tool Result 返回给模型
↓
模型继续决策或生成最终回复
模型产生的内容类似:
{
"toolName": "queryOrder",
"arguments": {
"orderId": "A10086"
}
}
这个 JSON 只是在表达:
模型认为下一步应该调用
queryOrder。
真正执行方法的仍然是 Java。
因此,一个非常重要的结论是:
Tool Call 是模型提出的行动建议,不是必须执行的系统命令。
二、模型选择了 Tool,Java 仍然可以拒绝
假设用户明确说:
我的订单还没到,你先帮我查一下,不要取消。
模型却返回:
{
"toolName": "cancelOrder",
"arguments": {
"orderId": "A10086",
"reason": "物流时间较长"
}
}
这里模型发生了 Tool 选择错误。
但系统不能因为 Tool Call 的 JSON 格式正确,就直接执行取消订单。
正确链路应该是:
模型提出 cancelOrder
↓
Java 执行前校验
↓
发现当前调用与用户明确意图冲突
↓
拒绝执行
↓
返回结构化拒绝结果
↓
模型重新选择 queryOrder 或 queryLogistics
例如返回:
{
"success": false,
"errorCode": "USER_INTENT_CONFLICT",
"retryable": false,
"message": "用户明确要求不要取消订单",
"recommendedAction": "QUERY_ORDER_OR_LOGISTICS"
}
这能够告诉模型:
-
当前 Tool 没有执行;
-
失败原因不是系统异常;
-
不要使用相同参数重试;
-
下一步应该查询订单或物流。
如果只返回:
{
"success": false,
"message": "失败"
}
模型可能不知道为什么失败,然后再次调用 cancelOrder,最终形成循环。
三、Java 怎么知道模型选错了 Tool?
Java 本身不是大模型,它通常无法直接理解:
"先别真的退。"
这种自然语言背后的全部语义。
因此,不能让 Java 通过大量关键词判断用户意图,例如:
if (userMessage.contains("别退")) {
reject();
}
这种规则非常脆弱。
用户还可能说:
-
暂时不要操作;
-
我只是想问问;
-
先查一下,别动订单;
-
我还没有决定退款。
更合理的方式是,把模型理解出的关键意图转换成结构化 State:
{
"intent": "CHECK_REFUND_ELIGIBILITY",
"operationMode": "READ_ONLY",
"userConfirmedRefund": false
}
然后为写操作设置明确前置条件:
applyRefund 必须满足:
1. operationMode = EXECUTE
2. userConfirmedRefund = true
3. 退款资格校验通过
4. 用户拥有订单权限
5. 金额合法
6. 不存在重复退款
因此,Java 并不需要完全理解模型为什么选错了。
Java只需要判断:
当前 Tool 所需的执行条件有没有满足。
如果没有满足,就拒绝执行。
四、Tool 执行前应该校验什么?
一个可靠的 Tool 执行器,至少应该检查以下内容。
1. Tool 白名单
模型只能调用系统允许的 Tool。
if (!allowedTools.contains(toolName)) {
throw new ToolRejectedException("TOOL_NOT_ALLOWED");
}
不能因为模型生成了:
deleteDatabase
executeShell
系统就尝试执行。
2. 参数 Schema
例如检查:
-
必填参数是否存在;
-
参数类型是否正确;
-
枚举值是否合法;
-
字符串是否超长;
-
数值是否超过范围。
即使模型支持结构化输出,Java 也不能完全信任模型传参。
3. 身份和资源归属
例如:
当前订单是否属于当前用户?
当前用户是否允许查询这笔订单?
当前用户是否有退款权限?
不能因为模型提供了一个 orderId,就允许查询其他用户的订单。
4. 当前任务状态
例如退款 Tool 只有在以下状态才能执行:
WAITING_USER_CONFIRMATION
↓
用户确认
↓
READY_TO_EXECUTE
如果当前仍然处于:
READ_ONLY
就应该拒绝执行写操作。
5. 业务前置条件
例如退款需要检查:
-
订单是否存在;
-
是否满足退款时间;
-
是否已经退款;
-
是否超过可退款金额;
-
商品类型是否支持退款。
这些都应该由确定性的 Java 规则处理。
6. 风险等级和审批
可以给不同 Tool 设置风险等级:
queryOrder READ
checkRefundEligibility READ
changeAddress WRITE
applyRefund HIGH_RISK_WRITE
deleteUser CRITICAL
低风险查询可以较自由调用。
写操作可能需要用户二次确认。
高风险操作还可能需要人工审批。
7. 幂等性
Agent 可能因为网络超时重复调用同一个 Tool:
applyRefund(A10086)
applyRefund(A10086)
Java 必须保证同一个业务请求不会被重复执行。
8. 最新业务状态
模型的判断可能基于几秒前甚至几分钟前的状态。
执行前必须重新读取最新数据,不能只依赖之前的 Tool Result。
9. 审计日志
需要记录:
-
哪个用户发起;
-
哪个模型选择了 Tool;
-
Tool 名称和参数;
-
为什么允许或拒绝;
-
最终执行结果;
-
traceId 或 requestId。
否则出现事故后,很难还原完整过程。
五、Agent 为什么能够连续调用多个 Tool?
一个真实任务通常无法通过一次 Tool 调用完成。
例如用户说:
支付服务刚才出错了,帮我看看原因。
Agent 第一步可能调用:
queryServiceStatus("payment")
返回:
{
"status": "RUNNING",
"errorRate": 0.32
}
Agent发现服务仍在运行,但错误率很高,于是调用:
queryRecentLogs("payment")
返回:
{
"mainError": "DATABASE_CONNECTION_TIMEOUT",
"occurrences": 128
}
Agent再调用:
queryDatabaseStatus()
最后发现数据库连接数已经耗尽。
整个过程是:
观察当前信息
↓
决定下一步动作
↓
调用 Tool
↓
获取 Tool Result
↓
更新判断
↓
继续调用或停止
这就是 Agent Loop。
可以简化为:
Observe
→ Decide
→ Act
→ Observe
每一次 Tool Result 都会成为下一次决策的新依据。
六、为什么不一开始调用所有 Tool?
假设运维 Agent 可以调用:
queryServiceStatus
queryRecentLogs
queryCpuUsage
queryDatabaseStatus
contactEngineer
一种简单做法是把所有 Tool 全部执行。
但这样会产生多个问题。
1. 浪费资源
可能调用了与当前问题无关的接口,增加:
-
Token;
-
网络请求;
-
响应时间;
-
系统负载;
-
调用成本。
2. 无法根据结果缩小范围
Agent Loop 的价值在于:
先获得一个结果
→ 根据结果判断最大的信息缺口
→ 再选择最有价值的 Tool
例如如果服务已经完全停止,下一步可能应该检查部署和进程,而不是立刻查询数据库。
3. 可能提前产生副作用
查询类 Tool 风险较低,但:
restartService
contactEngineer
cancelOrder
applyRefund
不能一开始全部执行。
不过,并不是所有 Tool 都必须严格串行。
对于彼此独立、低风险、没有副作用的查询,可以并行执行:
queryCpuUsage
queryMemoryUsage
queryServiceStatus
因此,更准确的原则是:
有依赖关系的步骤按结果推进;相互独立的低风险查询可以并行。
七、Agent Loop 为什么会陷入死循环?
假设 Agent 调用日志服务:
{
"toolName": "queryRecentLogs",
"arguments": {
"serviceName": "payment"
}
}
Tool 返回:
{
"success": false,
"errorCode": "LOG_SERVICE_TIMEOUT",
"retryable": true
}
模型随后反复调用:
queryRecentLogs
→ 超时
queryRecentLogs
→ 超时
queryRecentLogs
→ 超时
这就是无效循环。
此外,还有一种不容易发现的循环:
queryServiceStatus
→ queryRecentLogs
→ queryServiceStatus
→ queryRecentLogs
虽然没有连续调用同一个 Tool,但系统仍然没有获得新信息。
因此,不能只判断:
是否连续调用了相同 Tool。
系统还需要记录完整执行轨迹。
八、Agent Loop 应该设置哪些停止条件?
一个可靠 Agent 至少需要以下几类限制。
1. 最大步骤数
int maxSteps = 10;
防止模型无限规划。
2. 最大 Tool 调用次数
int maxToolCalls = 8;
步骤数和 Tool 调用数不一定相同,因为某一步可能直接生成回答。
3. 最大执行时间
Duration maxExecutionTime = Duration.ofSeconds(30);
不同任务应设置不同时间预算:
-
客服对话:通常几秒;
-
数据查询:可能几十秒;
-
数据分析:可能几分钟;
-
长任务 Agent:可能持续更久,但需要持久化和恢复。
4. 最大重试次数
不能把所有错误统一设置为重试 3 次。
应该根据错误类型决定:
INVALID_PARAMETER
→ 不重试,修正参数
PERMISSION_DENIED
→ 不重试,申请权限或转人工
ORDER_NOT_FOUND
→ 不使用相同订单号重试,询问用户
DATABASE_TIMEOUT
→ 有限重试
RATE_LIMITED
→ 等待后重试
5. 退避重试
可重试错误也不能立即高频调用。
例如:
第 1 次失败 → 等待 1 秒
第 2 次失败 → 等待 2 秒
第 3 次失败 → 等待 4 秒
仍然失败 → 降级或转人工
这种方式可以避免在系统故障时进一步增加压力。
6. 无进展检测
系统需要判断:
这次 Tool 调用是否带来了新信息?
例如:
相同 Tool + 相同参数 + 相同返回结果
连续出现时,就应该停止。
也可以检测:
A → B → A → B
这种交替循环。
九、什么时候应该停止排查?
Agent 不一定需要百分之百证明根因。
假设已经确认:
支付服务仍在运行
错误率为 32%
日志大量出现数据库连接超时
数据库连接数已经满
更可靠的回答不是假装完全确定:
根因就是数据库连接池耗尽。
而是区分:
已确认事实
当前推测
缺失信息
建议下一步
例如:
已确认:
支付服务错误率较高,日志中大量出现数据库连接超时,同时数据库连接数已达到上限。
当前判断:
连接池耗尽很可能是当前故障的主要原因。
尚未确认:
连接数耗尽是由慢查询、连接泄漏还是突发流量导致。
建议:
进一步检查活跃连接来源、连接池配置和慢查询记录。
这说明:
Agent 的目标不是无限追求绝对确定,而是在证据足够时给出带有不确定性说明的当前结论。
十、什么是 Agent State?
Agent 每调用一次 Tool,都需要记住之前发生了什么。
以退款任务为例:
用户目标:检查退款资格
订单号:A10086
用户要求:不要直接退款
订单状态:已签收
退款资格:通过
可退金额:399 元
已调用 Tool:queryOrder、checkRefundEligibility
当前阶段:等待用户确认
这些信息共同组成当前任务的 State。
State 可以理解为:
Agent 继续完成当前任务所需要的结构化事实、进度、约束和授权信息。
十一、State 和聊天记录有什么区别?
聊天记录可能是:
用户:帮我看看能不能退款,先不要真的退。
Agent:好的,我先查询订单。
Tool:订单已签收。
Agent:我再检查退款资格。
Tool:退款资格通过,可退 399 元。
而 State 可以是:
{
"taskId": "refund_001",
"intent": "CHECK_REFUND_ELIGIBILITY",
"orderId": "A10086",
"operationMode": "READ_ONLY",
"userConfirmedRefund": false,
"refundEligible": true,
"refundAmount": 399,
"taskStage": "WAITING_USER_CONFIRMATION"
}
两者区别是:
聊天记录:
帮助模型理解发生过什么。
State:
帮助系统确定当前做到哪一步,以及下一步允许做什么。
如果只有聊天记录,Java 每次都需要重新依赖模型理解:
用户是否确认过退款?
而使用 State,可以直接判断:
if (!state.isUserConfirmedRefund()) {
reject("USER_CONFIRMATION_REQUIRED");
}
十二、State 应该保存什么?
可以把 State 分成四类。
1. 任务目标
{
"intent": "CHECK_REFUND",
"operationMode": "READ_ONLY"
}
表示用户希望完成什么,以及是否允许执行写操作。
2. 业务事实
{
"orderId": "A10086",
"orderStatus": "DELIVERED",
"refundEligible": true,
"refundAmount": 399
}
这些是当前任务已经获得的关键事实。
3. 执行进度
{
"taskStage": "WAITING_USER_CONFIRMATION",
"toolCallCount": 2,
"retryCount": 0,
"completedSteps": [
"QUERY_ORDER",
"CHECK_REFUND_ELIGIBILITY"
]
}
用于恢复任务和防止重复执行。
4. 约束与授权
{
"userConfirmedRefund": false,
"approvalStatus": "NOT_REQUIRED",
"proposedAction": "APPLY_REFUND",
"executionStatus": "NOT_STARTED"
}
需要注意:
proposedAction = APPLY_REFUND
只是表示 Agent 建议退款。
它不等于:
已经获得退款授权。
十三、哪些内容不应该直接放进 State?
1. 全部聊天历史
聊天历史可以独立保存,但不能无限塞入当前 State。
State 应提取关键事实和约束。
2. 所有 Tool 原始返回数据
如果 Tool 返回几万行日志,不能全部放进 State。
更合理的是保存:
结果摘要
关键业务字段
错误码
requestId
数据版本
完整原始结果放到日志或独立存储中。
3. 密码、密钥和 Token
以下内容绝对不应进入 Agent State 或模型上下文:
数据库密码
云服务 Secret
支付私钥
管理员 Token
4. 用户的长期喜好
例如:
用户喜欢简洁回答
用户通常选择原路退款
用户偏好某种酒店
这类跨任务信息更接近 Long-term Memory,而不是当前任务 State。
简单区分:
State:
当前这件事情做到哪里了?
Memory:
过去有哪些信息可能对未来任务有用?
十四、State 为什么需要持久化?
如果 State 只存在 Java 内存中:
Map<String, AgentState> stateMap;
服务器一旦重启,任务状态就会丢失。
对于以下任务,通常需要持久化:
-
等待用户确认的退款;
-
等待人工审批的操作;
-
已经执行了部分写操作的流程;
-
运行时间较长的数据分析;
-
需要跨请求继续执行的任务;
-
涉及资金、订单或生产环境的任务。
可以保存到:
-
Redis;
-
MySQL;
-
PostgreSQL;
-
专门的任务状态存储。
例如:
{
"taskId": "refund_001",
"orderId": "A10086",
"taskStage": "WAITING_USER_CONFIRMATION",
"refundEligible": true,
"refundAmount": 399,
"userConfirmedRefund": false,
"stateVersion": 3
}
而以下任务通常不一定需要持久化:
-
普通问答;
-
几秒内完成的低风险查询;
-
失败后可以安全重新开始的任务。
是否持久化,主要看:
State 丢失后,重新执行的成本和风险有多大。
十五、用户中途改变要求时,State 怎么更新?
原来的 State:
{
"operationMode": "READ_ONLY",
"userConfirmedRefund": false
}
用户后来又说:
可以,直接退吧。
系统不需要把所有状态清空重来。
可以保留:
-
订单号;
-
已查询的订单信息;
-
之前的 Tool 调用;
-
退款资格结果。
只更新:
{
"operationMode": "EXECUTE",
"userConfirmedRefund": true
}
但真正执行前仍然需要重新检查最新业务状态。
原因是之前的退款资格结果可能已经过期:
之前检查允许退款
↓
其他请求已经完成退款
↓
当前 Agent 仍按旧结果执行
正确流程是:
更新用户最新意图
↓
保留可复用的任务状态
↓
执行前重新读取最新业务状态
↓
再次校验
↓
满足条件后执行
十六、State 并发:为什么最后写入不一定正确?
假设当前状态:
{
"taskId": "refund_001",
"operationMode": "READ_ONLY",
"userConfirmedRefund": false,
"stateVersion": 3
}
用户先说:
可以退款。
请求 A 读取 version=3,准备更新成:
EXECUTE
紧接着用户又说:
等等,先别退。
请求 B 也读取 version=3,准备更新成:
READ_ONLY
如果请求 B 先执行,State 已经变成用户最新要求:
READ_ONLY
但请求 A 随后才写入:
EXECUTE
旧请求就覆盖了用户的新指令。
这是一种典型并发问题。
十七、使用版本号防止旧状态覆盖新状态
State 中可以维护:
{
"stateVersion": 3
}
更新时要求数据库当前版本仍然是 3:
UPDATE agent_task
SET operation_mode = 'EXECUTE',
user_confirmed_refund = TRUE,
state_version = state_version + 1
WHERE task_id = 'refund_001'
AND state_version = 3;
如果更新行数为 0,说明当前状态已经被其他请求修改。
Java 应返回:
STATE_VERSION_CONFLICT
当前请求不能继续按照旧状态执行。
这类方式通常称为乐观并发控制。
它的核心思想是:
只有在状态从读取到提交期间没有发生变化时,才允许本次更新成功。
十八、发生版本冲突后应该怎么办?
错误做法:
状态更新失败
↓
继续执行退款
这可能造成:
State 显示用户已经撤销退款
但支付平台已经完成退款
合理流程是:
收到 STATE_VERSION_CONFLICT
↓
立即停止当前执行
↓
重新读取最新 State
↓
判断用户最新意图
↓
重新读取最新业务状态
↓
重新规划下一步
↓
满足条件后再更新或执行
不能直接覆盖,也不能让 LLM凭感觉决定使用哪个版本。
十九、两个 Agent 同时操作同一订单怎么办?
假设:
Agent A 准备退款
Agent B 准备取消订单
两边一开始查询到的状态都是:
PAID
如果两边都因为之前校验通过而执行,就可能产生冲突。
仅仅在执行前重新查询一次还不一定安全。
因为仍可能发生:
Agent A 查询:PAID
↓
Agent B 立即取消订单
↓
Agent A 根据刚才的查询继续退款
更可靠的做法是把状态校验和状态修改放到一个原子操作中:
UPDATE orders
SET status = 'REFUNDED'
WHERE order_id = 'A10086'
AND status = 'PAID';
如果成功更新一行,说明当前请求获得了状态修改权。
如果更新零行,说明订单已经被其他请求修改。
因此:
真正执行写操作时,不能只相信之前看到的状态快照。
二十、为什么还要记录状态变化事件?
只保存当前 State,可以知道:
现在是什么状态。
但无法知道:
为什么会变成这个状态。
因此可以记录关键事件:
10:00 用户查询退款资格
10:01 退款资格检查通过
10:02 用户确认退款
10:03 用户撤销退款确认
10:04 旧请求尝试执行,被版本冲突拒绝
这些事件主要用于:
-
审计;
-
排查事故;
-
分析并发问题;
-
恢复中断任务;
-
还原用户和 Agent 的操作顺序;
-
判断某次写操作为什么被允许或拒绝。
事件记录不一定要全部发送给模型。
可以在需要时提取与当前任务有关的摘要。
二十一、一个完整的 Agent 执行框架
可以把当前阶段的知识组合成如下流程:
用户发送请求
↓
模型理解意图
↓
更新结构化 State
↓
模型选择 Tool
↓
Java 检查 Tool 白名单
↓
Java 校验参数
↓
Java 校验用户权限
↓
Java 校验当前 State
↓
Java 校验业务前置条件
↓
Java 判断风险和审批要求
↓
执行 Tool
↓
返回结构化 Tool Result
↓
更新 State 和事件记录
↓
模型根据结果继续决策
↓
达到目标、预算上限或无进展条件后停止
如果出现并发更新:
发现 State Version Conflict
↓
停止旧执行
↓
读取最新 State
↓
重新判断
二十二、Java 侧可以如何分层?
不必在每个 Tool 中重复编写所有通用校验。
可以设计统一执行层:
public ToolResult executeTool(
ToolCall toolCall,
AgentContext context,
AgentState state
) {
validateToolWhitelist(toolCall);
validateSchema(toolCall);
validateAuthentication(context);
validatePermission(toolCall, context);
validateStatePreconditions(toolCall, state);
validateRiskPolicy(toolCall, context);
return toolRegistry.execute(toolCall);
}
通用能力可以集中处理:
-
Tool 白名单;
-
Schema 校验;
-
身份认证;
-
权限控制;
-
风险等级;
-
用户确认;
-
人工审批;
-
幂等性;
-
限流;
-
Trace;
-
审计。
具体业务 Tool 只负责自己的业务规则:
public RefundResult applyRefund(RefundCommand command) {
validateOrderOwnership(command);
validateRefundEligibility(command);
validateRefundAmount(command);
validateIdempotency(command);
return paymentService.refund(command);
}
这样可以避免在每个 Tool 中重复编写通用逻辑。
总结
这一阶段最重要的认知是:
模型只负责提出下一步行动,系统必须负责判断这个行动能否真正执行。
Tool Calling 的完整链路是:
模型选择 Tool
→ Java 校验
→ Java 执行或拒绝
→ Tool Result
→ 模型继续决策
Agent Loop 的本质是:
每次根据新结果更新判断
而不是不停调用 Tool。
State 的本质是:
当前任务的目标
+ 业务事实
+ 执行进度
+ 约束和授权
并发控制解决的是:
防止旧决策覆盖用户的新指令
最终可以把本阶段压缩成几句话:
Tool Call 是 proposal,不是 command。
模型可以选错 Tool,
但 Java 必须保证错误 Tool 无法突破安全边界。
Agent Loop 每一步都应该获得新信息或推动状态变化。
聊天记录帮助模型理解过去,
State 帮助系统控制现在和下一步。
Agent 可以基于旧状态作出错误决策,
但系统必须阻止旧决策改变新世界。
一个可靠 Agent 并不是"模型足够聪明"就能实现的。
它需要:
模型的动态判断
+ Java 的确定性校验
+ 可恢复的任务 State
+ 并发控制
+ 审计和停止条件
共同组成完整的工程系统。