从普通对话到 Agent:用 Java 实现一个最小旅行推荐 Agent
平时调用大模型时,我们通常把问题发给模型,然后等待一段文本回答:
用户问题 → 大模型 → 最终回答但如果我问:
我在上海,10 月有 4 天假期,喜欢自然风景和安静的小城,请推荐一个国内目的地,并安排几个主要景点。模型只依靠训练数据回答,会遇到几个问题:
- 它不知道我们程序里有哪些候选目的地;
- 它无法主动查询目的地的天气;
- 它不知道景点工具返回了什么;
- 它可能直接编出一份看起来合理的行程。
我们可以给模型提供工具,让它根据问题决定查什么,再把查询结果交还给它。只要这个过程能够持续循环,一个最小 Agent 就出现了。
这篇文章不用 LangChain4j 等 Agent 框架,而是直接使用 Java 写出循环。旅行推荐只是示例,重点是理解 Agent 为什么能够行动。
1. 普通对话为什么不是 Agent
一次最普通的模型调用大致是:
String answer = model.chat("推荐一个适合秋天旅行的城市");System.out.println(answer);程序只做了两件事:发送问题、接收回答。模型没有机会接触程序外部的信息,也不能在获得新信息后继续判断。
普通对话的生命周期只有一轮:
Question → Answer → EndAgent 的生命周期则可能包含多轮:
Question ↓Model decides to call a tool ↓Java executes the tool ↓Tool result is written back to messages ↓Model continues reasoning ↓Final answer这里最容易误解的一点是:
模型不会真正执行 Java 方法。模型只会生成“我想调用哪个工具,以及传什么参数”的结构化请求。
识别请求、执行方法、保存结果和决定是否进入下一轮,都是我们的 Java 程序负责的。
2. 最小 Agent 由什么组成
这个旅行 Agent 包含五个部分:
| 部分 | 作用 |
|---|---|
| LLM | 判断下一步应该调用工具还是直接回答 |
| Tool | 查询目的地、天气和景点 |
| State | 保存用户消息、模型响应和工具结果 |
| Loop | 把工具结果再次发给模型 |
| Stop Condition | 在得到最终答案或达到最大轮数时停止 |
可以把核心公式记成:
LLM + Tool ≠ Agent
LLM + Tool + State + Loop + Stop Condition = 最小 Agent我们的代码也按照这些职责拆分:
Main├── AgentLoop├── ChatModel│ └── OpenAiCompatibleModel└── ToolRegistry ├── DestinationTool ├── WeatherTool └── AttractionToolAgentLoop 不关心 HTTP 请求怎么发送,也不保存任何旅游数据。它只负责控制流程。
3. 先定义消息和模型响应
Agent 每一轮都要携带之前发生的事情,所以不能只保存最后一句文本。为了突出原理,我们先定义几个最小数据结构:
import com.fasterxml.jackson.databind.JsonNode;
public record Message( String role, String content, String toolCallId, java.util.List<ToolCall> toolCalls) { public static Message user(String content) { return new Message("user", content, null, java.util.List.of()); }
public static Message assistant(String content) { return new Message("assistant", content, null, java.util.List.of()); }
public static Message assistantToolCalls(java.util.List<ToolCall> calls) { return new Message("assistant", null, null, calls); }
public static Message tool(String toolCallId, String content) { return new Message("tool", content, toolCallId, java.util.List.of()); }}
public record ToolCall(String id, String name, JsonNode arguments) {}
public record ModelResponse(String content, java.util.List<ToolCall> toolCalls) { public boolean hasToolCalls() { return toolCalls != null && !toolCalls.isEmpty(); }}这里有三个容易被忽略的字段:
role表示消息来自用户、模型还是工具;toolCalls保存模型提出的工具调用;toolCallId告诉模型某条工具结果对应哪一次调用。
如果模型同时请求两个工具,不能只把结果作为普通文本塞回去。toolCallId 就像一次调用的快递单号,用于把请求和结果准确对应起来。
4. 给工具一个统一接口
每个工具至少需要说明三件事:它叫什么、参数长什么样、收到参数后如何执行。
import com.fasterxml.jackson.databind.JsonNode;
public interface Tool { String name();
String description();
JsonNode parametersSchema();
String execute(JsonNode arguments) throws Exception;}parametersSchema 使用 JSON Schema 描述参数。模型会读取这些定义,但模型看到工具定义,并不等于它已经执行了工具。
先实现一个目的地查询工具。为了让示例可以直接理解,这里使用本地模拟数据:
public final class DestinationTool implements Tool { private final ObjectMapper mapper;
public DestinationTool(ObjectMapper mapper) { this.mapper = mapper; }
@Override public String name() { return "search_destinations"; }
@Override public String description() { return "根据出行月份和旅行偏好搜索国内候选目的地"; }
@Override public JsonNode parametersSchema() { return mapper.createObjectNode() .put("type", "object") .set("properties", mapper.createObjectNode() .set("month", mapper.createObjectNode() .put("type", "integer") .put("description", "出行月份,1 到 12")) .set("preference", mapper.createObjectNode() .put("type", "string") .put("description", "旅行偏好,例如自然、安静或美食"))); }
@Override public String execute(JsonNode arguments) { int month = arguments.path("month").asInt(); String preference = arguments.path("preference").asText();
if (month == 10 && (preference.contains("自然") || preference.contains("安静"))) { return "候选目的地:腾冲、婺源、阿尔山。" + "腾冲适合火山、湿地和慢节奏旅行;" + "婺源适合古村和田园风景;" + "阿尔山适合森林和秋季自然景观。"; } return "候选目的地:大理、泉州、桂林。请结合天气和景点继续判断。"; }}天气和景点工具使用相同结构:
public final class WeatherTool implements Tool { // name(): get_weather // 参数:destination、month
@Override public String execute(JsonNode arguments) { String destination = arguments.path("destination").asText(); int month = arguments.path("month").asInt();
return switch (destination) { case "腾冲" -> month == 10 ? "模拟数据:腾冲 10 月通常温和,昼夜温差较明显,建议准备薄外套。" : "模拟数据:请在真实应用中查询天气 API。"; case "婺源" -> "模拟数据:秋季较舒适,可能有降雨。"; case "阿尔山" -> "模拟数据:10 月气温较低,需要准备保暖衣物。"; default -> "没有找到该目的地的模拟天气数据。"; }; }}
public final class AttractionTool implements Tool { // name(): get_attractions // 参数:destination、days
@Override public String execute(JsonNode arguments) { String destination = arguments.path("destination").asText();
return switch (destination) { case "腾冲" -> "景点:和顺古镇、北海湿地、火山地质公园。" + "4 天可安排古镇 1 天、湿地 1 天、火山及周边 1 天,并预留机动时间。"; case "婺源" -> "景点:篁岭、李坑、思溪延村。"; case "阿尔山" -> "景点:阿尔山国家森林公园、驼峰岭天池。"; default -> "没有找到该目的地的模拟景点数据。"; }; }}正文使用模拟数据,是为了观察 Agent 的控制流程。这些文字不是实时天气或旅行保证。真实项目中,只需把 execute 替换为天气、地图或景点 API 调用,Agent Loop 不需要改变。
5. 用 ToolRegistry 执行工具
模型返回的是工具名称,Java 程序需要根据名称找到真正的对象:
public final class ToolRegistry { private final Map<String, Tool> tools;
public ToolRegistry(List<Tool> toolList) { this.tools = toolList.stream() .collect(Collectors.toUnmodifiableMap(Tool::name, Function.identity())); }
public List<Tool> definitions() { return List.copyOf(tools.values()); }
public String execute(ToolCall call) { Tool tool = tools.get(call.name()); if (tool == null) { return "工具执行失败:不存在名为 " + call.name() + " 的工具。"; }
try { return tool.execute(call.arguments()); } catch (Exception exception) { return "工具执行失败:" + exception.getMessage(); } }}这里没有遇到异常就让整个程序退出,而是把错误转换成工具结果。这样模型下一轮能看见错误,并尝试改正参数或选择其他工具。
不过,生产环境不能把包含密钥、路径或内部堆栈的信息原样发给模型。工具错误应该经过过滤,只保留模型完成任务所需的信息。
6. 把模型调用封装起来
Agent Loop 不应该依赖某一家模型服务,所以先定义接口:
public interface ChatModel { ModelResponse chat(List<Message> messages, List<Tool> tools) throws Exception;}然后使用 JDK HttpClient 实现一个 OpenAI-compatible 客户端。请求的核心结构类似:
{ "model": "your-model", "messages": [ { "role": "user", "content": "推荐一个适合 10 月旅行的安静目的地" } ], "tools": [ { "type": "function", "function": { "name": "search_destinations", "description": "根据月份和偏好搜索候选目的地", "parameters": { "type": "object", "properties": { "month": { "type": "integer" }, "preference": { "type": "string" } }, "required": ["month", "preference"] } } } ]}模型可能返回普通回答,也可能返回工具调用:
{ "content": null, "tool_calls": [ { "id": "call_001", "type": "function", "function": { "name": "search_destinations", "arguments": "{\"month\":10,\"preference\":\"自然、安静\"}" } } ]}模型客户端要把这段响应转换成前面的 ModelResponse:
public final class OpenAiCompatibleModel implements ChatModel { private final HttpClient httpClient = HttpClient.newHttpClient(); private final ObjectMapper mapper; private final URI endpoint; private final String apiKey; private final String model;
public OpenAiCompatibleModel( ObjectMapper mapper, String endpoint, String apiKey, String model ) { this.mapper = mapper; this.endpoint = URI.create(endpoint); this.apiKey = apiKey; this.model = model; }
@Override public ModelResponse chat(List<Message> messages, List<Tool> tools) throws Exception { ObjectNode requestBody = buildRequest(messages, tools);
HttpRequest request = HttpRequest.newBuilder(endpoint) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(requestBody))) .build();
HttpResponse<String> response = httpClient.send( request, HttpResponse.BodyHandlers.ofString() );
if (response.statusCode() / 100 != 2) { throw new IllegalStateException( "模型请求失败,HTTP 状态码:" + response.statusCode() ); }
return parseResponse(mapper.readTree(response.body())); }}buildRequest 负责把 Message 和 Tool 转换为供应商要求的 JSON,parseResponse 负责提取文本与工具调用。这两个方法属于协议适配代码,并不是 Agent 的核心。
需要特别注意:OpenAI-compatible 并不意味着所有平台的字段、接口地址和 Tool Calling 行为完全一致。更换模型供应商时,通常只调整 OpenAiCompatibleModel,不要把兼容性差异塞进 AgentLoop。
配置可以来自环境变量:
String endpoint = System.getenv("MODEL_ENDPOINT");String apiKey = System.getenv("MODEL_API_KEY");String modelName = System.getenv("MODEL_NAME");不要把 API Key 写进代码或提交到 Git。
7. Agent 的核心:循环
前面都只是准备工作。真正让普通模型调用变成 Agent 的,是下面这个循环:
public final class AgentLoop { private final ChatModel model; private final ToolRegistry toolRegistry; private final int maxTurns;
public AgentLoop(ChatModel model, ToolRegistry toolRegistry, int maxTurns) { this.model = model; this.toolRegistry = toolRegistry; this.maxTurns = maxTurns; }
public String run(String userInput) throws Exception { List<Message> messages = new ArrayList<>(); messages.add(Message.user(userInput));
for (int turn = 1; turn <= maxTurns; turn++) { System.out.println("[Turn " + turn + "] Asking model...");
ModelResponse response = model.chat( List.copyOf(messages), toolRegistry.definitions() );
if (!response.hasToolCalls()) { if (response.content() == null || response.content().isBlank()) { throw new IllegalStateException("模型既没有回答,也没有请求工具"); } messages.add(Message.assistant(response.content())); return response.content(); }
messages.add(Message.assistantToolCalls(response.toolCalls()));
for (ToolCall call : response.toolCalls()) { System.out.println("[Tool] " + call.name() + " " + call.arguments());
String result = toolRegistry.execute(call); System.out.println("[Result] " + result);
messages.add(Message.tool(call.id(), result)); } }
throw new IllegalStateException("达到最大轮数,Agent 仍未生成最终回答"); }}这段代码需要逐步看。
第一步:保存状态
List<Message> messages = new ArrayList<>();messages.add(Message.user(userInput));messages 就是最小状态。它会从第一轮一直传到最后一轮。
如果每轮只把最新的工具结果发给模型,模型就会忘记用户最初想去哪里、旅行几天,也不知道这个结果对应哪一次调用。
第二步:让模型决定下一步
ModelResponse response = model.chat(messages, tools);程序没有硬编码“先查城市,再查天气,最后查景点”。它只把当前状态和可用工具交给模型,由模型选择下一步。
这正是 Agent 与普通工作流的差别之一。固定工作流的步骤由程序提前决定;Agent 的下一步通常由模型结合当前状态决定。
第三步:判断是否结束
if (!response.hasToolCalls()) { return response.content();}模型不再调用工具并给出文本回答时,说明它认为信息已经足够,循环正常结束。
第四步:保存模型的工具请求
messages.add(Message.assistantToolCalls(response.toolCalls()));不能只保存工具执行结果。模型发出的调用请求本身也是消息历史的一部分。
第五步:执行工具并写回结果
String result = toolRegistry.execute(call);messages.add(Message.tool(call.id(), result));工具结果被写回后,循环进入下一轮。模型这时才能真正“看到”现实世界返回了什么。
因此,Agent 的核心不是模型突然学会执行 Java 代码,而是系统持续完成下面这件事:
模型提出动作 → 程序执行动作 → 结果写回上下文 → 模型继续判断8. 组装并运行
在 Main 中创建模型、工具和循环:
public class Main { public static void main(String[] args) throws Exception { ObjectMapper mapper = new ObjectMapper();
ChatModel model = new OpenAiCompatibleModel( mapper, System.getenv("MODEL_ENDPOINT"), System.getenv("MODEL_API_KEY"), System.getenv("MODEL_NAME") );
ToolRegistry tools = new ToolRegistry(List.of( new DestinationTool(mapper), new WeatherTool(mapper), new AttractionTool(mapper) ));
AgentLoop agent = new AgentLoop(model, tools, 8);
String answer = agent.run(""" 我在上海,10 月有 4 天假期,喜欢自然风景和安静的小城。 请推荐一个国内目的地,并安排几个主要景点。 """);
System.out.println("\n最终回答:\n" + answer); }}一次可能的执行轨迹是:
[Turn 1] Asking model...[Tool] search_destinations {"month":10,"preference":"自然、安静"}[Result] 候选目的地:腾冲、婺源、阿尔山……
[Turn 2] Asking model...[Tool] get_weather {"destination":"腾冲","month":10}[Result] 模拟数据:腾冲 10 月通常温和,昼夜温差较明显……
[Turn 3] Asking model...[Tool] get_attractions {"destination":"腾冲","days":4}[Result] 景点:和顺古镇、北海湿地、火山地质公园……
[Turn 4] Asking model...
最终回答:推荐腾冲。它比较符合自然风景和安静旅行的偏好……注意,日志里的工具顺序不是我们在 Java 中写死的,而是模型根据已有信息决定的。不同模型可能选择不同目的地,也可能在同一轮并行请求天气和景点工具。
9. 为什么必须设置停止条件
如果没有 maxTurns,模型可能反复调用同一个工具:
查天气 → 得到结果 → 再查天气 → 得到相同结果 → ……产生循环的原因可能包括:
- 工具描述不够清楚;
- 工具结果没有正确写回;
toolCallId对应错误;- 模型没有意识到信息已经足够;
- 工具不断返回无法完成任务的错误。
所以最小 Agent 至少需要这些终止规则:
模型返回最终文本 → 正常结束达到最大轮数 → 强制停止模型/API 请求失败 → 报告基础设施错误用户主动取消 → 立即停止(真实应用建议增加)最大轮数不是完美方案,但它是防止失控循环、无止境消耗 Token 和反复调用外部服务的最后保险。
10. 工具错误为什么也要写回模型
假设模型调用了不存在的工具:
{ "name": "search_hotels", "arguments": { "destination": "腾冲" }}ToolRegistry 可以返回:
工具执行失败:不存在名为 search_hotels 的工具。把它作为对应的工具结果写回后,模型下一轮可能意识到当前只提供了目的地、天气和景点工具,然后调整方案。
这也是 Agent Loop 的价值:一次动作失败不一定意味着整个任务失败。只要错误是可恢复的,模型就有机会依据新的观察继续行动。
不过并非所有错误都应该重试。API Key 无效、权限不足、计费异常等基础设施错误,继续循环通常没有意义,应直接结束并交给程序或人工处理。
11. 应该测试什么
模型回答具有随机性,不适合断言它一定推荐“腾冲”。更稳定的测试应该针对 Agent 的控制逻辑:
- 模型直接回答时只执行一轮;
- 模型请求工具后,Java 确实执行了对应工具;
- 工具结果带着正确的
toolCallId写回消息历史; - 模型连续调用多个工具时循环能够继续;
- 未知工具不会让程序直接崩溃;
- 达到最大轮数时循环一定停止。
测试时可以使用假的 ChatModel,让它按顺序返回预设响应:
ChatModel fakeModel = new SequenceChatModel(List.of( new ModelResponse(null, List.of( new ToolCall("call_1", "search_destinations", arguments) )), new ModelResponse("推荐腾冲。", List.of())));这样测试的是我们写的循环,而不是某个外部模型今天恰好给出的答案。
12. 从最小示例走向真实应用
理解循环后,可以逐步替换外围组件:
- 把
WeatherTool的模拟数据替换为真实天气 API; - 增加交通、酒店和预算工具;
- 在预订或付款前加入人工确认;
- 记录工具耗时、Token 使用量和每轮状态;
- 对工具参数做更严格的校验和权限控制;
- 最后再尝试用 LangChain4j 等框架重写,对比框架隐藏了哪些细节。
涉及预订、付款、删除数据或发送消息的工具,不能因为模型请求了就直接执行。真正的 Coding Agent 或业务 Agent 还需要权限边界、沙箱、审批和审计日志。
总结
这篇文章里真正重要的代码不是旅行数据,也不是 HTTP 请求,而是 AgentLoop:
1. 保存消息状态2. 把状态和工具定义发给模型3. 读取模型提出的工具调用4. 由 Java 执行真实工具5. 把工具结果写回消息历史6. 继续下一轮,直到模型回答或触发停止条件模型负责决策,工具负责接触外部信息,循环负责让决策和现实结果不断连接。
理解这套最小结构后,再去看旅行 Agent、Coding Agent、Research Agent 或各种 Agent 框架,会发现它们虽然工具更多、状态更复杂,但最里面仍然是同一个循环。
If this article helped you, please share it with others!
Some information may be outdated






