Spring AI、LangChain4j 等框架已经降低了 Java 应用接入 GPT、Claude、Gemini、DeepSeek 的门槛。Prompt、Memory、RAG、Tool Calling、MCP 等能力也逐渐齐备。真正阻碍 Agent 进入核心业务的,往往不再是“能不能调用模型”,而是执行失控之后能否定位、拦截和恢复。
来源摘要没有明确说明所谓“最后一块拼图”对应某个具体产品或 API。结合其强调的生产环境语境,可以把它理解为一套 Agent 运行时闭环:每一步都可观察,高风险动作可控制,失败任务可恢复,成本和权限有明确边界。
从聊天接口到业务执行器
普通聊天应用的主要路径很短:接收问题、调用模型、返回文本。Agent 则会把模型输出转换成实际动作,例如查询订单、修改工单、发送邮件或触发部署。这让系统多出了几类传统接口不常见的问题:
- 同一个目标可能触发多轮模型调用和多个工具,耗时与费用会不断累积。
- 模型生成的工具参数可能语法正确,但业务含义错误。
- 工具可能产生不可逆副作用,简单重试会造成重复扣款或重复通知。
- Prompt、模型、知识库和工具版本共同决定结果,只记录最终答案无法复盘。
- Memory 和 RAG 会接触用户数据,日志记录不当可能扩大敏感信息暴露范围。
因此,生产 Agent 不应被当作一个更聪明的 Controller。更合适的模型是受约束的工作流执行器:大模型负责提出下一步,Java 运行时负责验证、授权、执行和记录。
运行时闭环需要记录什么
一次 Agent 任务至少应该拥有稳定的 runId,并为每轮推理和每次工具调用分配独立的 stepId。观测数据需要覆盖四个层面:
| 层面 | 建议记录的内容 |
|---|---|
| 请求 | 租户、用户、目标、Agent 版本、入口 trace ID |
| 推理 | 模型、Prompt 版本、耗时、Token 用量、停止原因 |
| 工具 | 工具名、参数摘要、权限判断、幂等键、执行结果 |
| 任务 | 当前状态、累计步数、累计费用、最终结果、失败原因 |
这里的“参数摘要”不等于原样打印参数。身份证号、访问令牌、完整聊天记录等内容应在进入日志系统前脱敏。对于 Prompt 和知识库内容,可以记录版本号、哈希或文档 ID,而不是无限制保存正文。
状态机也要显式存在。一个实用的最小集合是:
CREATED -> RUNNING -> WAITING_APPROVAL -> RUNNING -> SUCCEEDED
\-> FAILED
\-> CANCELLED
\-> TIMED_OUT
一旦状态被持久化,任务就能暂停审批、超时终止,并在进程重启后继续执行。若所有状态只保存在一次 HTTP 请求的调用栈里,Agent 很难可靠处理长任务。
可以这样实践:给工具调用加上预算、超时和审计
下面是一个只依赖 Java 标准库的最小示例。它不绑定某个模型 SDK,而是演示 Agent 框架外层应具备的运行时约束。保存为 AgentRuntimeDemo.java,使用 JDK 21 运行:
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class AgentRuntimeDemo {
record Policy(int maxSteps, Duration toolTimeout, Set<String> allowedTools) {}
static final class RuntimeGuard {
private final String runId = UUID.randomUUID().toString();
private final Policy policy;
private int steps;
RuntimeGuard(Policy policy) {
this.policy = policy;
}
<T> T callTool(String tool, Map<String, Object> args, Callable<T> action)
throws Exception {
if (!policy.allowedTools().contains(tool)) {
throw new SecurityException("Tool is not allowed: " + tool);
}
if (++steps > policy.maxSteps()) {
throw new IllegalStateException("Agent step budget exceeded");
}
String stepId = UUID.randomUUID().toString();
Instant startedAt = Instant.now();
audit("tool.started", stepId, tool, redact(args), null);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var future = executor.submit(action);
try {
T result = future.get(policy.toolTimeout().toMillis(), TimeUnit.MILLISECONDS);
audit("tool.succeeded", stepId, tool, Map.of(),
Duration.between(startedAt, Instant.now()).toMillis());
return result;
} catch (Exception e) {
future.cancel(true);
audit("tool.failed", stepId, tool,
Map.of("error", e.getClass().getSimpleName()),
Duration.between(startedAt, Instant.now()).toMillis());
throw e;
}
}
}
private Map<String, Object> redact(Map<String, Object> args) {
return args.entrySet().stream().collect(java.util.stream.Collectors.toMap(
Map.Entry::getKey,
e -> Set.of("token", "password", "content").contains(e.getKey())
? "***" : e.getValue()
));
}
private void audit(String event, String stepId, String tool,
Map<String, Object> details, Long elapsedMs) {
System.out.printf(
"event=%s runId=%s stepId=%s tool=%s details=%s elapsedMs=%s%n",
event, runId, stepId, tool, details, elapsedMs
);
}
}
public static void main(String[] args) throws Exception {
var guard = new RuntimeGuard(new Policy(
5,
Duration.ofSeconds(2),
Set.of("order.lookup", "ticket.create")
));
String order = guard.callTool(
"order.lookup",
Map.of("orderId", "A-1001", "token", "secret-value"),
() -> "status=PAID"
);
System.out.println(order);
}
}
运行命令:
java AgentRuntimeDemo.java
接入 Spring AI 或 LangChain4j 时,可以把相同逻辑放在工具执行器、拦截器或装饰器中。生产实现还应把标准输出替换为结构化日志和 OpenTelemetry span,并将 runId、stepId 写入日志上下文。
这个示例刻意没有自动重试。对于只读查询,可以在明确的超时和退避策略下重试;对于创建订单、退款、发送消息等操作,必须先设计幂等键。不能确认幂等性的工具,宁可进入人工处理队列,也不要让 Agent 自行重复执行。
高风险动作必须越过审批门
工具白名单只能回答“Agent 能否调用这个工具”,还不能回答“当前用户能否以这些参数执行”。权限判断至少要同时考虑用户身份、租户、资源范围和动作风险。
例如,查询订单可以直接执行,退款则应先生成操作提案:
{
"runId": "2cb63bd7-cc91-4d77-87c0-8d01911b537d",
"tool": "payment.refund",
"arguments": {
"orderId": "A-1001",
"amount": 29900,
"currency": "CNY"
},
"risk": "HIGH",
"status": "WAITING_APPROVAL"
}
审批通过后,服务端应使用已保存并签名或哈希校验的参数执行,不能重新询问模型生成一份参数。否则审批者看到的内容和最终执行内容可能不是同一个动作。
MCP 可以统一工具发现和调用方式,但协议统一不等于权限天然安全。MCP Server 仍应被视为外部依赖:限制可访问工具,校验输入输出,设置网络超时,并对凭据采用最小权限。
上线前检查:先限制自治范围
Java Agent 的成熟不只取决于模型效果,也取决于传统工程能力能否覆盖非确定性执行路径。上线时可以按下面的顺序收紧风险:
- 从只读、低风险工具开始,不要直接开放删除、支付和发布能力。
- 设置最大推理轮数、单工具超时、任务总超时和费用上限。
- 为有副作用的工具设计幂等键,并区分可重试与不可重试错误。
- 持久化任务状态,支持取消、审批、恢复和失败转人工。
- 记录模型、Prompt、知识库与工具版本,但对输入输出进行脱敏。
- 使用离线用例和生产回放评估升级,避免只凭几段对话判断效果。
- 为模型服务、向量库和 MCP Server 准备降级策略与熔断机制。
真正补齐生产拼图的,不是再增加一个会调用工具的接口,而是把 Agent 纳入企业已有的身份、权限、审计、可观测性和故障恢复体系。模型可以决定“建议做什么”,最终“允许做什么、实际做了什么、失败后怎么办”仍应由确定性的 Java 代码掌控。