技术博客

Agent 开发入门(五):你的第一个 Agent — 纯手写,不用框架

不用任何框架,纯 Java 手写一个能调工具、有记忆的完整 Agent。看清每一个齿轮怎么转。

发布时间

阅读信息

约 25 分钟

主题标签

AI Agent / 入门 / Java

前四篇我们分别学了四个零件:

  • 第一篇:怎么调 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。

AI Agent入门Java百炼实战
上一篇 Agent 开发入门(六):从手写到框架 — 框架帮我们解决了什么 下一篇 Agent 开发入门(四):让模型说人话 — Structured Output