mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2772 words
7 minutes
从普通对话到 Agent:用 Java 实现一个最小旅行推荐 Agent
2026-05-15

从普通对话到 Agent:用 Java 实现一个最小旅行推荐 Agent#

平时调用大模型时,我们通常把问题发给模型,然后等待一段文本回答:

用户问题 → 大模型 → 最终回答

但如果我问:

我在上海,10 月有 4 天假期,喜欢自然风景和安静的小城,
请推荐一个国内目的地,并安排几个主要景点。

模型只依靠训练数据回答,会遇到几个问题:

  • 它不知道我们程序里有哪些候选目的地;
  • 它无法主动查询目的地的天气;
  • 它不知道景点工具返回了什么;
  • 它可能直接编出一份看起来合理的行程。

我们可以给模型提供工具,让它根据问题决定查什么,再把查询结果交还给它。只要这个过程能够持续循环,一个最小 Agent 就出现了。

这篇文章不用 LangChain4j 等 Agent 框架,而是直接使用 Java 写出循环。旅行推荐只是示例,重点是理解 Agent 为什么能够行动。

1. 普通对话为什么不是 Agent#

一次最普通的模型调用大致是:

String answer = model.chat("推荐一个适合秋天旅行的城市");
System.out.println(answer);

程序只做了两件事:发送问题、接收回答。模型没有机会接触程序外部的信息,也不能在获得新信息后继续判断。

普通对话的生命周期只有一轮:

Question → Answer → End

Agent 的生命周期则可能包含多轮:

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
└── AttractionTool

AgentLoop 不关心 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 负责把 MessageTool 转换为供应商要求的 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. 从最小示例走向真实应用#

理解循环后,可以逐步替换外围组件:

  1. WeatherTool 的模拟数据替换为真实天气 API;
  2. 增加交通、酒店和预算工具;
  3. 在预订或付款前加入人工确认;
  4. 记录工具耗时、Token 使用量和每轮状态;
  5. 对工具参数做更严格的校验和权限控制;
  6. 最后再尝试用 LangChain4j 等框架重写,对比框架隐藏了哪些细节。

涉及预订、付款、删除数据或发送消息的工具,不能因为模型请求了就直接执行。真正的 Coding Agent 或业务 Agent 还需要权限边界、沙箱、审批和审计日志。

总结#

这篇文章里真正重要的代码不是旅行数据,也不是 HTTP 请求,而是 AgentLoop

1. 保存消息状态
2. 把状态和工具定义发给模型
3. 读取模型提出的工具调用
4. 由 Java 执行真实工具
5. 把工具结果写回消息历史
6. 继续下一轮,直到模型回答或触发停止条件

模型负责决策,工具负责接触外部信息,循环负责让决策和现实结果不断连接。

理解这套最小结构后,再去看旅行 Agent、Coding Agent、Research Agent 或各种 Agent 框架,会发现它们虽然工具更多、状态更复杂,但最里面仍然是同一个循环。

Share

If this article helped you, please share it with others!

从普通对话到 Agent:用 Java 实现一个最小旅行推荐 Agent
https://mizuki.mysqil.com/posts/java-minimal-travel-agent/
Author
梦幻晨风
Published at
2026-05-15
License
CC BY-NC-SA 4.0

Some information may be outdated

Table of Contents