Agent 开发入门(五):你的第一个 Agent — 纯手写,不用框架
不用任何框架,纯 Java 手写一个能调工具、有记忆的完整 Agent。看清每一个齿轮怎么转。
前四篇我们分别学了四个零件:
- 第一篇:怎么调 API,拿到模型的回复
- 第二篇:Function Calling,让模型决定调哪个工具
- 第三篇:上下文管理,让模型记住对话历史
- 第四篇:Structured Output,让模型输出结构化数据
现在,把它们组装起来。
不用任何框架,纯手写,让你看清 Agent 的每一个齿轮怎么转。
Agent 到底是什么
很多人第一次听到 Agent 会觉得很神秘。其实拆开来看,Agent 就是一个循环:
用户输入 → 构建消息 → 调模型 → 模型要调工具?
↓ 是
执行工具 → 把结果塞回消息 → 再调模型
↓ 否
输出给用户 → 等待下一轮输入
这个模式有个名字叫 ReAct(Reasoning + Acting)。模型先推理,再行动,再推理,直到任务完成。
听起来简单,但细节藏在魔鬼里。我们一步步来。
项目结构
整个 Agent 放在一个文件里,用内部类组织:
SimpleAgent.java
├── Tool 接口
├── WeatherTool(查天气)
├── CalculatorTool(数学计算)
├── TimeTool(查时间)
├── ChatHistory(上下文管理,复用第三篇)
├── SimpleAgent(核心循环)
└── main 方法
零第三方依赖,JDK 17+ 自带的 HttpClient 搞定一切。
完整代码
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
public class SimpleAgent {
// ==================== Tool 接口 ====================
interface Tool {
String name();
String description();
String parametersSchema(); // JSON Schema 字符串
String execute(Map<String, String> args);
}
// ==================== 工具实现 ====================
static class WeatherTool implements Tool {
@Override public String name() { return "get_weather"; }
@Override public String description() { return "查询指定城市的天气情况"; }
@Override public String parametersSchema() {
return """
{
"type": "object",
"properties": {
"city": { "type": "string", "description": "城市名称,如北京、上海" }
},
"required": ["city"]
}
""";
}
@Override
public String execute(Map<String, String> args) {
String city = args.getOrDefault("city", "未知");
// 模拟天气数据
Map<String, String> fakeWeather = Map.of(
"北京", "晴,15°C,东北风3级",
"上海", "多云,18°C,东风2级",
"广州", "小雨,22°C,南风1级",
"深圳", "阴,21°C,东南风2级"
);
return fakeWeather.getOrDefault(city, city + ":晴,20°C,微风");
}
}
static class CalculatorTool implements Tool {
@Override public String name() { return "calculate"; }
@Override public String description() { return "执行数学计算,支持加减乘除和括号"; }
@Override public String parametersSchema() {
return """
{
"type": "object",
"properties": {
"expression": { "type": "string", "description": "数学表达式,如 (3+5)*2" }
},
"required": ["expression"]
}
""";
}
@Override
public String execute(Map<String, String> args) {
String expr = args.getOrDefault("expression", "");
try {
double result = evalSimple(expr.trim());
// 整数结果去掉小数点
if (result == Math.floor(result)) {
return String.valueOf((long) result);
}
return String.valueOf(result);
} catch (Exception e) {
return "计算失败:" + e.getMessage();
}
}
// 简单四则运算(不依赖第三方库)
private double evalSimple(String expr) {
// 去掉空格
expr = expr.replaceAll("\\s+", "");
return parseExpr(new int[]{0}, expr);
}
private double parseExpr(int[] pos, String s) {
double result = parseTerm(pos, s);
while (pos[0] < s.length() && (s.charAt(pos[0]) == '+' || s.charAt(pos[0]) == '-')) {
char op = s.charAt(pos[0]++);
double term = parseTerm(pos, s);
result = op == '+' ? result + term : result - term;
}
return result;
}
private double parseTerm(int[] pos, String s) {
double result = parseFactor(pos, s);
while (pos[0] < s.length() && (s.charAt(pos[0]) == '*' || s.charAt(pos[0]) == '/')) {
char op = s.charAt(pos[0]++);
double factor = parseFactor(pos, s);
result = op == '*' ? result * factor : result / factor;
}
return result;
}
private double parseFactor(int[] pos, String s) {
if (s.charAt(pos[0]) == '(') {
pos[0]++; // 跳过 '('
double result = parseExpr(pos, s);
pos[0]++; // 跳过 ')'
return result;
}
int start = pos[0];
if (s.charAt(pos[0]) == '-') pos[0]++;
while (pos[0] < s.length() && (Character.isDigit(s.charAt(pos[0])) || s.charAt(pos[0]) == '.')) {
pos[0]++;
}
return Double.parseDouble(s.substring(start, pos[0]));
}
}
static class TimeTool implements Tool {
@Override public String name() { return "get_current_time"; }
@Override public String description() { return "获取当前日期和时间"; }
@Override public String parametersSchema() {
return """
{
"type": "object",
"properties": {}
}
""";
}
@Override
public String execute(Map<String, String> args) {
return LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
}
}
// ==================== ChatHistory ====================
static class ChatHistory {
private final List<Map<String, Object>> messages = new ArrayList<>();
private final int maxTokens;
ChatHistory(int maxTokens) {
this.maxTokens = maxTokens;
}
void addMessage(String role, String content) {
messages.add(Map.of("role", role, "content", content));
trim();
}
// 添加 tool_calls 消息(assistant 调用工具时)
void addAssistantToolCall(JsonNode toolCallsNode) {
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("role", "assistant");
msg.put("content", (Object) null);
msg.put("tool_calls", toolCallsNode);
messages.add(msg);
}
// 添加工具执行结果
void addToolResult(String toolCallId, String toolName, String result) {
messages.add(Map.of(
"role", "tool",
"tool_call_id", toolCallId,
"name", toolName,
"content", result
));
}
List<Map<String, Object>> getMessages() {
return Collections.unmodifiableList(messages);
}
// 简单裁剪:超出限制时删除最早的非 system 消息
private void trim() {
// 粗略估算:每条消息按 200 token 算
while (messages.size() > maxTokens / 200 && messages.size() > 2) {
// 保留第一条 system 消息
if ("system".equals(messages.get(1).get("role"))) {
messages.remove(2);
} else {
messages.remove(1);
}
}
}
}
// ==================== SimpleAgent 核心 ====================
private static final String API_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
private static final String MODEL = "qwen-plus";
private static final int MAX_ITERATIONS = 10; // 防止死循环
private final String apiKey;
private final Map<String, Tool> tools;
private final ChatHistory history;
private final ObjectMapper mapper = new ObjectMapper();
private final HttpClient httpClient = HttpClient.newHttpClient();
SimpleAgent(String apiKey) {
this.apiKey = apiKey;
this.tools = new LinkedHashMap<>();
this.history = new ChatHistory(4000);
// 注册工具
registerTool(new WeatherTool());
registerTool(new CalculatorTool());
registerTool(new TimeTool());
// 系统提示
history.addMessage("system",
"你是一个智能助手,可以查天气、做数学计算、查询当前时间。" +
"需要用到工具时直接调用,不要解释你要做什么。" +
"工具返回结果后,用自然语言回答用户。"
);
}
void registerTool(Tool tool) {
tools.put(tool.name(), tool);
}
// 核心:处理一轮用户输入
String chat(String userInput) throws Exception {
history.addMessage("user", userInput);
int iterations = 0;
while (iterations < MAX_ITERATIONS) {
iterations++;
// 调用模型
JsonNode response = callModel();
JsonNode choice = response.path("choices").get(0);
JsonNode message = choice.path("message");
String finishReason = choice.path("finish_reason").asText();
// 模型直接回答
if ("stop".equals(finishReason)) {
String content = message.path("content").asText();
history.addMessage("assistant", content);
return content;
}
// 模型要调工具
if ("tool_calls".equals(finishReason)) {
JsonNode toolCalls = message.path("tool_calls");
// 把 assistant 的 tool_calls 消息加入历史
history.addAssistantToolCall(toolCalls);
// 执行每个工具调用
for (JsonNode toolCall : toolCalls) {
String toolCallId = toolCall.path("id").asText();
String toolName = toolCall.path("function").path("name").asText();
String argsJson = toolCall.path("function").path("arguments").asText();
String result = executeTool(toolName, argsJson);
// 把工具结果加入历史
history.addToolResult(toolCallId, toolName, result);
}
// 继续循环,让模型根据工具结果生成回复
continue;
}
// 其他情况(length 等)
return "(回复被截断,finish_reason=" + finishReason + ")";
}
return "(超过最大迭代次数 " + MAX_ITERATIONS + ",任务终止)";
}
private String executeTool(String toolName, String argsJson) {
Tool tool = tools.get(toolName);
if (tool == null) {
return "错误:工具 " + toolName + " 不存在";
}
try {
// 解析参数
Map<String, String> args = new LinkedHashMap<>();
JsonNode argsNode = mapper.readTree(argsJson);
argsNode.fields().forEachRemaining(e -> args.put(e.getKey(), e.getValue().asText()));
return tool.execute(args);
} catch (Exception e) {
// 工具执行失败,返回错误信息给模型,让模型决定怎么处理
return "工具执行失败:" + e.getMessage();
}
}
private JsonNode callModel() throws Exception {
// 构建请求体
ObjectNode body = mapper.createObjectNode();
body.put("model", MODEL);
// 消息列表
ArrayNode messagesNode = mapper.createArrayNode();
for (Map<String, Object> msg : history.getMessages()) {
ObjectNode msgNode = mapper.createObjectNode();
msg.forEach((k, v) -> {
if (v == null) {
msgNode.putNull(k);
} else if (v instanceof JsonNode) {
msgNode.set(k, (JsonNode) v);
} else {
msgNode.put(k, v.toString());
}
});
messagesNode.add(msgNode);
}
body.set("messages", messagesNode);
// 工具定义
ArrayNode toolsNode = mapper.createArrayNode();
for (Tool tool : tools.values()) {
ObjectNode toolNode = mapper.createObjectNode();
toolNode.put("type", "function");
ObjectNode funcNode = mapper.createObjectNode();
funcNode.put("name", tool.name());
funcNode.put("description", tool.description());
funcNode.set("parameters", mapper.readTree(tool.parametersSchema()));
toolNode.set("function", funcNode);
toolsNode.add(toolNode);
}
body.set("tools", toolsNode);
// 发请求
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + apiKey)
.POST(HttpRequest.BodyPublishers.ofString(body.toString()))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("API 错误 " + response.statusCode() + ": " + response.body());
}
return mapper.readTree(response.body());
}
// ==================== main ====================
public static void main(String[] args) throws Exception {
String apiKey = System.getenv("DASHSCOPE_API_KEY");
if (apiKey == null || apiKey.isBlank()) {
System.err.println("请设置环境变量 DASHSCOPE_API_KEY");
System.exit(1);
}
SimpleAgent agent = new SimpleAgent(apiKey);
Scanner scanner = new Scanner(System.in);
System.out.println("智能助手已启动(输入 exit 退出)");
System.out.println("我能查天气、做计算、告诉你现在几点。");
System.out.println("---");
while (true) {
System.out.print("你:");
String input = scanner.nextLine().trim();
if (input.isEmpty()) continue;
if ("exit".equalsIgnoreCase(input)) break;
try {
String reply = agent.chat(input);
System.out.println("助手:" + reply);
} catch (Exception e) {
System.err.println("出错了:" + e.getMessage());
}
System.out.println();
}
System.out.println("再见!");
}
}
代码大约 250 行,一个文件搞定。
关键设计决策
为什么用 while 循环而不是递归
工具调用可能连续发生多次。比如用户问”北京今天天气怎么样,顺便算一下 (15+18)/2”,模型可能先调 get_weather,再调 calculate,两次工具调用之后才给出最终回答。
用递归的话,调用栈会随着工具调用次数增长,而且不好控制最大深度。while 循环更直观,也更容易加 MAX_ITERATIONS 限制。
最大迭代次数
MAX_ITERATIONS = 10 是一道保险。
没有这个限制,如果工具一直返回错误,模型可能陷入”调工具 → 失败 → 再调 → 再失败”的死循环,白白消耗 token 和钱。
10 次对于大多数任务绰绰有余。如果你的 Agent 需要更复杂的规划,可以适当调高,但建议不要超过 20。
工具执行失败怎么办
注意 executeTool 里的 catch 块:
return "工具执行失败:" + e.getMessage();
我们没有抛出异常,而是把错误信息作为工具结果返回给模型。这样模型能感知到工具失败了,可以选择重试、换个方式,或者直接告诉用户”我查不到这个信息”。
如果直接抛异常,整个对话就中断了,用户体验很差。
tool_calls 消息的格式
这是最容易踩坑的地方。当模型返回 tool_calls 时,你必须把这条 assistant 消息原封不动地加入历史,然后再加工具结果。顺序不能乱,格式不能改。
assistant: { tool_calls: [...] } ← 必须先加这条
tool: { tool_call_id: "...", content: "..." } ← 再加结果
如果只加工具结果、不加 assistant 的 tool_calls 消息,API 会报错。
运行演示
设置好环境变量,直接运行:
export DASHSCOPE_API_KEY=your_key_here
javac SimpleAgent.java
java SimpleAgent
来看几轮对话:
场景一:直接回答(不调工具)
你:你好,你能做什么?
助手:你好!我可以帮你做这几件事:
1. 查天气 — 告诉我城市名就行
2. 数学计算 — 支持加减乘除和括号
3. 查当前时间
有什么需要帮忙的?
场景二:单工具调用
你:北京今天天气怎么样?
助手:北京今天晴天,气温 15°C,东北风 3 级,出门可以带件外套。
背后发生了什么:模型调用了 get_weather(city="北京"),拿到结果后组织成自然语言回复。
场景三:多工具调用
你:上海和广州的气温差多少度?
助手:上海今天 18°C,广州 22°C,相差 4 度。广州比上海暖和一些。
这里模型连续调用了两次 get_weather,然后自己做了减法。你也可以观察到,如果问题更复杂,模型可能还会调用 calculate 来辅助计算。
场景四:工具 + 推理
你:现在几点了?再过 90 分钟是几点?
助手:现在是 14:32:15。再过 90 分钟是 16:02:15。
模型先调 get_current_time,拿到当前时间,然后自己推算出 90 分钟后的时间(这个简单加法它直接算了,没有调 calculate)。
这个手写 Agent 有什么不足
写完这个 Agent,我们应该感到满足——它真的能跑,真的能用。但也要清醒地看到它的局限:
1. 工具注册太原始
现在是手动 registerTool(new WeatherTool()),工具多了之后管理起来很麻烦。框架通常提供注解或自动扫描机制。
2. 没有流式输出
现在是等模型完整回复后才打印,用户要等很久。流式输出(SSE)需要额外处理,代码复杂度会翻倍。
3. 错误重试逻辑缺失
API 调用失败了直接抛异常,没有重试。生产环境里网络抖动很常见。
4. 没有并发工具调用
模型有时会在一次响应里返回多个 tool_calls,我们现在是串行执行的。如果两个工具互相独立,并行执行能省不少时间。
5. 上下文管理太粗糙
ChatHistory 里的 token 估算是按条数算的,不够精确。真实场景需要按 token 数裁剪,还要考虑工具定义本身占用的 token。
6. 没有持久化
对话历史存在内存里,进程重启就没了。
这些问题,框架都帮你解决了。下一篇我们就来看看,用 Spring AI 或 LangChain4j 重写这个 Agent,代码量能减少多少,以及框架在哪些地方做了我们没做的事。
小结
我们从零手写了一个完整的 Agent:
- ReAct 循环:接收输入 → 调模型 → 执行工具 → 再调模型 → 输出
- 三个工具:天气、计算器、时间
- 上下文管理:多轮对话有记忆
- 防护机制:最大迭代次数、工具错误处理
最重要的是,你现在知道 Agent 的每一行代码在做什么。框架帮你封装的,不再是黑盒。
下一篇预告:从手写到框架 — 看看框架帮我们解决了什么。我们用 Spring AI 重写这个 Agent,对比两个版本,看框架的价值在哪里。
这是「Agent 开发入门」系列的第五篇。关注公众号粒方Lab,跟我一起从零搭建 AI Agent。