工具是 LLM 与真实世界之间的合约。好的工具 = 好的合约。Agent中的工具的好坏和原来我们开发一个接口的好坏的评判标准可差别太大了 我认为,一个好的工具,应该是:命名精确(LLM 一看就知道什么时候该调)、参数明确(LLM 不需要猜格式)、返回结构化(LLM 拿到结果知道下一步该干嘛)、安全兜底(即使 LLM 传错参数也不会炸)。
命名:动词_名词,一看就知道做什么
工具名是 LLM 决定「调不调它」的第一线索。好名字 = 一个动词(做什么)+ 一个名词(对什么)。 比如项目中的好命名:
query_travel_order — 查询差旅单
submit_travel_approval — 提交差旅审批
cancel_booking — 取消预订
check_travel_order_conflicts — 检查行程冲突
review_planner_result — 审核规划结果
save_flight_api_key — 保存机票 API Key
命名清单: 原则:读写分开命名,让 LLM 从名字就能区分「调了有没有副作用」。
Description:写给 LLM 看的「使用说明书」
description 不是写给人看的注释——它是 LLM 决策「什么时候该调这个工具」的核心依据。好的 description 要回答三个问题:这个工具做什么、什么时候该调、返回什么/有什么限制。 正例:query_weather 的 description
@Tool(name = "query_weather",
description = "联网查询目的地城市的天气信息(首选工具,免费)。"
+ "支持今天及未来 2 天(共 3 天)的天气预报;"
+ "若 date 超出此范围,工具会返回 beyond_range=true,Agent 应改用 weather-mcp 工具查询。")
三个关键信息一句话说清:①做什么(联网查天气)②限制(只支持 3 天)③超出时怎么办(返回信号 + 建议改用其他工具)。 正例:plan_itinerary 的 description(复杂工具怎么写)
description = "往返行程规划客观计算引擎:输入候选交通/酒店 + 你(LLM)预先产出的偏好分,"
+ "在内存里做去×住×返组合、过滤非法组合、算总价/总耗时并归一化、做 policy 软约束检查、"
+ "按 (去程分+酒店分+返程分)/3 合成偏好分、按三维权重算综合分,选出 4 类代表方案。"
+ "本工具只做客观数学,绝不解读偏好文本;偏好判断必须由你在 scores 参数里给出。"
关键设计:明确告诉 LLM「你该做什么,我做什么」的职责边界——偏好打分是 LLM 的活,计算排序是工具的活。这防止了 LLM 把偏好文本直接传进来期望工具「理解」。 正例:submit_travel_approval 的 description
description = "一次性创建差旅申请单(差旅单)和审批单,并将两者双向关联。"
+ "执行步骤:① 根据出行信息生成差旅单(DRAFT);② 提交审批单(PENDING)并绑定差旅单ID;"
+ "③ 将差旅单状态更新为 SUBMITTED 并写入审批单ID。"
+ "返回差旅单ID(orderId)和审批单ID(processInstanceId)。"
+ "日期字段必须为 YYYY-MM-DD 格式(例如 2026-07-15),否则会返回参数错误。"
做了什么(三步)、返回什么(两个 ID)、格式要求(YYYY-MM-DD + 例子)——一目了然。 description 写作模板: [做什么,一句话]。 [什么时候该调 / 前置条件]。 [返回什么 / 有什么约束 / 超限怎么办]。 [格式要求(如有日期/JSON)]。
参数设计:让 LLM 不需要猜
安全参数走 ToolExecutionContext,不暴露给 LLM userId、sessionId 等安全敏感字段永远不要让 LLM 传——通过 AgentSessionContext 透明注入:
// ✅ 正确:userId 从上下文获取,LLM 看不到这个参数
public String queryTravelPolicy(
AgentSessionContext sessionCtx, // 框架自动注入,对 LLM 不可见
@ToolParam(name = "city") String city) {
String userId = sessionCtx.getUserId(); // 安全获取
}
// ❌ 错误:如果把 userId 暴露为 @ToolParam,LLM 可能传错/幻觉
public String queryTravelPolicy(
@ToolParam(name = "user_id") String userId, // 危险!
@ToolParam(name = "city") String city)
凡是有唯一正确值且该值已存在于系统上下文中的参数,都不应该交给 LLM 填。
✅如何避免模型幻觉导致用户id传错?
我们在代码中提供了很多工具,这些工具需要去做数据库的CRUD操作,比如查询行程单、取消行程单、查询用户的API KEY等。 这些工具,我们可以通过提供参数的方式,让LLM传入上下文中的用户ID,但是实际运行时会发现,有的时候LLM回传错,多 LLMentor 必选参数 vs 可选参数
// 必选:没有默认值,LLM 必须传
@ToolParam(name = "destination", description = "目的地城市") String destination
// 可选:有合理的「不传也行」语义
@ToolParam(name = "status", description = "状态过滤...可选", required = false) String status
@ToolParam(name = "date", description = "查询日期...可选,不填则返回全量 3 天预报", required = false) String date
可选参数的 description 里必须说明「不传时的行为」。 否则 LLM 不知道不传会怎样,可能每次都硬编一个值。 格式约束写进 description,并给示例
// ✅ 好:格式 + 示例 + 违反后果
@ToolParam(name = "departure_date",
description = "出发日期,必须为 YYYY-MM-DD 格式,例如 2026-07-15") String departureDate
// ✅ 好:枚举值直接列出
@ToolParam(name = "status",
description = "状态过滤,可选值: DRAFT/SUBMITTED/APPROVED/REJECTED/COMPLETED/CANCELLED",
required = false) String status
// ✅ 好:复杂结构给出 JSON schema 示意
@ToolParam(name = "scores",
description = "LLM事先对每个候选打的偏好分 JSON。结构固定:{ transport_scores:{id:0-10,...}, hotel_scores:{id:0-10,...} }") String scores
LLM 是"看说明书干活"的——你写得越精确,它传参越准确。 用一个结构化参数替代太多碎片参数?需要权衡
// 方案A:多个具名参数(项目选择)
public String submitTravelApproval(ctx,
@ToolParam(name = "destination") String destination,
@ToolParam(name = "departure_city") String departureCity,
@ToolParam(name = "departure_date") String departureDate,
@ToolParam(name = "return_date") String returnDate,
@ToolParam(name = "purpose") String purpose)
// 方案B:一个大 JSON 参数
public String submitTravelApproval(ctx,
@ToolParam(name = "travel_info", description = "JSON {...}") String travelInfoJson)
gogo-agent 选了方案 A,原因:每个参数有独立的 description 和校验,LLM 更容易逐个填对;JSON 参数 LLM 容易生成格式错误。只有 plan_itinerary 的 candidates 和 scores 用了 JSON 参数——因为它们本身就是结构化数据透传,不适合拆成 20 个参数。 经验法则:5 个以内的独立语义字段 → 拆成具名参数;结构化数据透传 / 动态字段 → 用 JSON 参数。
返回值
带「信号」的返回——引导 LLM 下一步行为 好的工具不只返回「数据」,还返回「接下来该干嘛」的信号:
// 信号1:需要用户确认(needConfirm)
return new ConfirmRequiredResult(false, true, orderId, currentStatus.getCode(),
"该差旅单已审批通过,取消后不可恢复...请用户明确确认后再操作(传入 force=true)。");
// → LLM 看到 needConfirm=true,就知道要先问用户
// 信号2:超出能力范围,建议切换(beyond_range)
return JSON.toJSONString(Map.of(
"beyond_range", true,
"requested_date", date,
"max_date", maxDate.toString(),
"note", "wttr.in 仅支持 3 天内的天气预报,请改用 weather-mcp 查询"));
// → LLM 看到 beyond_range=true,知道该换 MCP 工具
// 信号3:功能不可用,给出引导(available=false)
return JSON.toJSONString(Map.of(
"available", false,
"note", "新闻查询功能需配置 app.news-api-key,请联系管理员开通"));
// → LLM 看到 available=false,知道该告诉用户
// 信号4:关联操作提醒(associatedBookings + affectedBookingsMessage)
return new CancelResult(true, orderId, ..., associatedBookings,
"该行程存在 3 条关联预订(机票2张、酒店1间),请提醒用户是否需要取消。");
// → LLM 看到非空的关联预订,知道要追问用户
工具返回里的"信号字段"是你控制 LLM 后续行为的最强杠杆——用它引导 LLM 而不是靠提示词重复叮嘱。 返回紧凑摘要,不要返回整个数据集
// ✅ plan_itinerary:计算完存 Redis,只返回摘要给 LLM
itineraryPlanStore.save(userId, fullResultJson); // 完整结果存 Redis
return buildCompactSummary(proposals); // 只返回 4 个方案的核心指标
// ✅ review_planner_result:从 Redis 读取完整结果,只返回审核结论
String fullPlan = itineraryPlanStore.load(userId); // 从 Redis 读
return buildReviewSummary(reviews, bestId); // 只返回通过/不通过/建议
// ❌ 反例:把 50 条航班的完整 JSON 直接 return
// 会导致上下文膨胀、token 暴涨、模型注意力分散
工具返回占用的是 Agent 的上下文窗口。返回越短,Agent 能跑的轮次越多、推理越准。大数据走 Redis/文件中转,只给 LLM 看摘要。
入参校验:在工具里防住 LLM 的幻觉
LLM 生成的参数不可信——可能格式错、可能是空、可能是幻觉出来的值。工具必须在入口处做完整校验:
// 模式:收集所有错误 → 一次性返回清晰的错误信息
List<String> errors = new ArrayList<>();
if (isBlank(destination)) errors.add("destination(目的地城市)不能为空");
if (isBlank(departureCity)) errors.add("departure_city(出发城市)不能为空");
String dateErr = validateDate(departureDate, "departure_date(出发日期)");
if (dateErr != null) errors.add(dateErr);
if (!errors.isEmpty()) {
return toJson(ErrorResult.of("INVALID_PARAM", String.join(";", errors)));
}
// 日期逻辑校验
if (LocalDate.parse(departureDate).isAfter(LocalDate.parse(returnDate))) {
return toJson(ErrorResult.of("INVALID_DATE_RANGE", "出发日期不能晚于返回日期"));
}
设计要点: - 收集所有错误再返回,不要遇到第一个就 return——LLM 一次拿到所有问题,下一次调用能一口气修对。 - 错误信息写给 LLM 看:要包含参数名 + 期望格式 + 实际值,让 LLM 知道怎么修。 - 日期格式用正则+parse 双保险:Pattern.compile("^\d{4}-\d{2}-\d{2}$") + LocalDate.parse() 捕获 DateTimeParseException。
幂等提交(防重复创建)
submit_travel_approval 在创建差旅单前先检查是否已存在完全一致的生效中差旅单:
private TravelOrder findDuplicateActiveOrder(String userId, String destination,
String departureCity, String departureDate, String returnDate, String purpose) {
List<TravelOrder> actives = travelOrderRepository.findActiveByUserIdAndDateRange(
userId, List.of(DRAFT, SUBMITTED, APPROVED), departureDate, returnDate);
for (TravelOrder o : actives) {
if (equalsSafe(o.getDestination(), destination)
&& equalsSafe(o.getDepartureCity(), departureCity)
&& equalsSafe(o.getDepartureDate(), departureDate)
&& equalsSafe(o.getReturnDate(), returnDate)
&& equalsSafe(o.getPurpose(), purpose)) {
return o; // 命中幂等
}
}
return null;
}
命中后直接返回已有差旅单信息,模型会告知用户「已有相同申请」,避免了 LLM 的不确定性导致同一份信息被提交多次。
异常处理:绝不让异常穿透到框架
// ✅ 正确:所有业务逻辑包在 try-catch 里,返回结构化错误
try {
// ... 业务逻辑 ...
} catch (Exception e) {
logger.error("[TOOL][cancel_travel_order] 取消失败 orderId={}", orderId, e);
return toJson(ErrorResult.of("INTERNAL_ERROR", "取消出差申请失败:" + e.getMessage()));
}
// ✅ 外部 API 调用:超时 + 异常 → 结构化降级
try {
String body = webClient.get().uri(url)
.retrieve().bodyToMono(String.class)
.block(Duration.ofSeconds(8)); // 明确超时
} catch (Exception e) {
return JSON.toJSONString(Map.of("error", "天气查询暂时不可用", "detail", e.getMessage()));
}
如果工具抛异常到框架,框架会把它包装成 Tool execution failed: ... 文本返回给 LLM——这既难看,也浪费了 LLM 一轮推理去理解错误。自己 catch、自己返回结构化错误信息,LLM 才能优雅处理。
写后验证(查库确认状态)
每个写操作成功后都会反查数据库验证状态是否正确落库:
private String verifyOrderStatus(String orderId, TravelOrderStatus expectedStatus, String toolName) {
Optional<TravelOrder> verifyOpt = travelOrderRepository.findByOrderId(orderId);
if (verifyOpt.isEmpty()) { return errorJson("查库后差旅单不存在"); }
if (verifyOpt.get().getStatus() != expectedStatus) { return errorJson("状态与预期不符"); }
return null; // 验证通过
}
这是一种防御性编程:在分布式环境/事务边界不明确时,用"写后读验证"兜底,确保返回给 LLM 的状态信息与数据库实际一致。
读写分离:把查询和操作拆成不同的工具
// 查询工具(只读,无副作用,可多次调用无害)
@Tool(name = "query_travel_order") → TravelOrderReadTools
@Tool(name = "query_booking_record") → BookingReadTools
@Tool(name = "check_travel_order_conflicts") → TravelOrderConflictTools
@Tool(name = "query_travel_policy") → PolicyTools
// 操作工具(有副作用,需确认/需幂等)
@Tool(name = "submit_travel_approval") → TravelOrderWriteTools
@Tool(name = "cancel_travel_order") → TravelOrderWriteTools
@Tool(name = "cancel_booking") → BookingWriteTools
为什么拆开? - LLM 调查询工具毫无风险:调错了只是多花 token,不会造成业务影响。 - LLM 调写工具有真实后果:所以写工具需要更严格的校验、幂等、确认门禁。 - 注册到不同 Agent 时更灵活:只给 InfoAgent 注册 read 工具,它物理上不可能乱改数据。 - 避免工具太多LLM会调错
工具间协作:通过 Redis/上下文中转而非巨参数
当一个工具的输出是另一个工具的输入时,不要让 LLM 做数据搬运工——用 Redis 做跨工具结果中转:
// 工具A:plan_itinerary —— 计算完存 Redis
itineraryPlanStore.save(userId, fullResultJson);
return compactSummary; // 只给 LLM 看摘要
// 工具B:review_planner_result —— 从 Redis 读完整数据
String fullPlan = itineraryPlanStore.load(userId);
// → 不需要 LLM 把巨大的方案 JSON 从 A 的输出搬到 B 的输入
这解决了三个问题: 1. 防上下文膨胀:完整规划结果可能 10K+ token,如果经 LLM 中转,每轮推理都要重新处理它。 2. 防 JSON 损坏:LLM 搬运大 JSON 时经常丢字段/改格式/截断,直接从 Redis 读则保证完整。 3. 跨 Agent 复用:规划 Agent 和审核 Agent 是不同的 prototype 实例,不共享内存,只能靠外部存储接力。