# agent-from-scratch-java **Repository Path**: shenzjd/agent-from-scratch-java ## Basic Information - **Project Name**: agent-from-scratch-java - **Description**: 面向 Java 开发者的 Agent 底层原理项目,不依赖 Spring AI、LangChain4j 等框架,用纯 Java 手写 Agent 核心循环,让你从“会调 API”变成“懂造 Agent”。 - **Primary Language**: Java - **License**: Apache-2.0 - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 9 - **Forks**: 0 - **Created**: 2026-10-26 - **Last Updated**: 2026-11-04 ## Categories & Tags **Categories**: Uncategorized **Tags**: Java, Agent, TypeScript ## README # agent-from-scratch-java ![Java](https://img.shields.io/badge/Java-21-orange) ![Spring Boot](https://img.shields.io/badge/Spring%20Boot-4.1.2-brightgreen) ![Maven](https://img.shields.io/badge/Maven-3.9.11-blue) ![Tests](https://img.shields.io/badge/tests-146%20green-success) 用 **Java 21 + Spring Boot** 从零实现一个 LLM Agent 运行时:路由、流式、工具调用、Agent 循环全部手写,**刻意不引入 Spring AI / LangChain4j 等现成框架**——目标是搞明白 Agent 框架的每一层到底是怎么造出来的。 ## 为什么有这个项目 市面上的 Agent 框架把太多东西藏在注解和自动装配后面。这个项目反其道而行: - **从零手写**:SSE 解析、tool_calls 分片归并、路由兜底、Agent while 循环……每一行都能读到。 - **技术选型收窄**:JDK `HttpClient`(网络)+ Jackson(JSON)+ Spring(仅 DI / 自动配置 / Web 端点),零其余依赖。 ## 功能特性 - **多厂商接入** —— 任何 OpenAI 兼容端点(DeepSeek / Qwen / GLM / Ollama / vLLM)零代码接入,纯配置。 - **动态模型路由** —— 任务分类 → 能力过滤 → 成本排序 → 健康沉底四级链;hint 只是提示不是命令,过滤后无候选自动放宽。 - **失败兜底** —— 调用失败自动换下一个候选(`max-attempts` 上限);DOWN 厂商沉底不剔除,仍可最后兜底。 - **SSE 流式** —— 逐 token 推送;"0 delta"兜底窗口(首字未出即失败可换模型重试),中途断流直报;行级 idle 超时防流停摆。 - **工具调用** —— 注册表统一执行,未知工具 / 执行异常 / 返回 null 全部兜成 error result 回传模型,调用方永不 catch 工具异常。 - **Agent 循环** —— 模型自主多轮(决策 → 调工具 → 回传 → 再决策)直到终答;到达轮数上限时去掉工具并追加 system 指令强收终答;流式 `tool_calls` 分片按 index 静默归并。 - **可观测** —— 每次调用发布路由决策 / 尝试 / 终态三事件;结构化单行日志;内存指标聚合(环形窗口 128 样本 P50/P95)。 - **前缀缓存纪律** —— 工具集会话内恒定、注册序即序列化序、tools 置于 messages 前、消息只追加不重排,最大化厂商侧前缀缓存命中。 ## 架构 ``` agent-from-scratch-java (根 pom, 聚合工程) ├── agent-sdk SDK 核心(零 Spring,仅依赖 jackson + slf4j-api) │ ├── api 厂商无关领域模型与 SPI(零 Spring、零网络,公共契约) │ ├── provider OpenAI 兼容协议适配(SSE + 行级超时 + tool_calls 归并) │ ├── routing 路由链 + 兜底执行 + 观测事件发布 │ ├── obs 结构化日志 + 内存指标聚合(零 Spring) │ ├── tools 工具注册表默认实现 │ └── agent Agent 循环默认实现(零 Spring) ├── agent-spring-boot-starter Spring Boot 装配(自动配置 + 属性绑定 + Tool bean 收集) └── agent-app 应用入口 = SDK 第一使用方(示例端点 + mock 工具) ├── web 四组端点:调试 / 流式 / 工具 / Agent 循环 └── tools MockWeatherTool 示例工具(@Component,starter 自动收集) ``` 依赖方向严格单向:**agent-app → agent-spring-boot-starter → agent-sdk**,SDK 核心不知道 Spring 存在。 ### 核心对象转换链 ``` ChatMessage×N + RouteHint? + tools │ ▼ ChatRequest 调用方的"意图"(厂商/模型无关) │ [路由链选模型] ▼ ModelRef "某厂商的某模型"指针 ▼ ChatCommand 执行指令(模型已定,provider 唯一入参) ▼ HTTP OpenAI 兼容协议 ▼ ChatResponse provider + model + message + usage + latency ``` ### 路由链 ``` 候选收集(全部已配置模型) → TaskFilter 按 hint.task 过滤(未声明任务的模型视为通用,保留) → CapabilityFilter 按 hint.required 能力子集过滤(带工具自动注入 FUNCTION_CALLING) → CostFirstSorter preferCheap 时按成本档位稳定升序,否则保持声明序 → HealthFilter DOWN 厂商稳定沉底(不剔除) → 兜底执行 按 max-attempts 逐个尝试,全败抛 RoutingException ``` ### Agent 循环(P6) ``` for round in 1..maxRounds: 非强收轮:携带工具集(恒定)发起调用 ├─ 返回 tool_calls → 注册表执行(失败兜 error result) │ → 追加 assistant(toolCalls) + role=tool 消息 │ → 下一轮(流式路径发 onRound 事件) └─ 无 tool_calls → 终答,结束 强收轮(第 maxRounds 轮):去掉工具 + 追加 system 指令 → 必出终答 ``` ## 快速开始 环境要求:JDK 21(Maven wrapper 自带,无需本地安装)。 ```bash # 跑全部测试(175 个用例) ./mvnw -B test # 启动应用(默认 6153 端口) ./mvnw -pl agent-app spring-boot:run ``` 配置你的厂商(`agent-app/src/main/resources/application-dev.yml`,gitignore 不入库): ```yaml llm: gateway: providers: deepseek: base-url: https://api.deepseek.com/v1 api-key: ${DEEPSEEK_API_KEY} models: - id: deepseek-chat cost: STANDARD capabilities: [ FUNCTION_CALLING ] max-context: 8192 ``` ## 作为 SDK 使用(v0.6.0 起) 引入 starter(坐标 `com.agent:agent-spring-boot-starter:0.6.0`)+ 写好 `llm.gateway.*` 配置后, 注入 `AgentClient` 门面一行调用——自动绑定容器内全部 Tool bean、自动走完整 Agent 循环。 完整可运行示例见 `agent-sdk-demo` 模块(唯一依赖 starter;启动即演示会话记忆 + 工具调用): ```java @Service class MyService { private final AgentClient agent; // starter 自动装配 String demo() { return agent.ask("北京天气怎么样?"); // 终答文本直接出 } } ``` | 方法 | 用途 | |---|---| | `ask(String prompt)` | 最简:终答文本出(挂注册表全部工具 + 默认 system prompt) | | `ask(String prompt, String systemPrompt)` | 带人设(显式覆盖默认) | | `askInSession(String sessionId, String prompt)` | **会话调用(P7)**:自动携带该 sessionId 最近历史,终答后 user/assistant 对入史 | | `askInSession(String sessionId, String prompt, String systemPrompt)` | 会话调用 + 显式人设(人设不写入历史) | | `ask(ChatRequest request)` | 完整控制:返回 `AgentResult`(usage / 逐轮工具记录) | | `askStream(String prompt, AgentStreamHandler h)` | 流式:所有轮增量实时透传 | 会话记忆默认存内存(LRU,容量 `llm.gateway.memory.sessions-cap`);接数据库/Redis 时实现 `ConversationStore` 接口声明为 bean 即可替换默认实现。SPI 支持两种等价路径: **窗口式**(覆写 `save` 全量覆盖,同 Spring AI Repository 形态)与**增量式** (覆写 `append` 只 INSERT + `find(sessionId, lastN)` 尾部读取即 `ORDER BY ... LIMIT`, 每轮读写恒为 O(maxHistoryMessages))。SDK 会话调用走的是增量路径,两种实现都兼容。 扩展工具 = 实现 `Tool` 接口(`spec()` 声明 JSON Schema + `execute()` 执行)并声明为 bean, starter 自动收集,无需任何注册代码;自定义 `LlmCallListener` bean 同样自动进入观测链。 ## API 端点 | 端点 | 说明 | |---|---| | `POST /llm/chat` | 单次对话(支持 `providerId`+`modelId` 钉死模型调试) | | `POST /llm/chat/stream` | SSE 流式对话(delta / done / error 事件) | | `POST /llm/chat/tools` | 单轮工具回路(带工具轨迹) | | `POST /llm/agent/stream` | **Agent 循环 SSE**(delta / tool / done / error 事件) | | `POST /llm/agent/session` | **会话对话(P7)**:`sessionId` + `message` → 携带历史的终答(普通 JSON) | | `GET /llm/tools` | **工具清单(P8b)**:注册表全量工具(name / source / description,source 区分 local 与 `mcp:`) | | `GET /llm/mcp/health` | **MCP 健康快照(P8b)**:各服务器 UP/DOWN、失败原因、工具数(声明序) | | `GET /llm/stats` | 调用指标快照(次数 / 失败 / token / P50/P95 延迟) | | `GET /llm/health` | 厂商健康快照 | | `POST /llm/route/dry-run` | 路由干跑:解释逐级过滤结果,不调用模型 | Agent 循环演示(内置 mock 天气工具,无需配真实 key 也能跑通流程): ```bash curl -N -X POST localhost:6153/llm/agent/stream \ -H 'Content-Type: application/json' \ -d '{"message":"北京天气怎么样?"}' ``` 会话演示(同 `sessionId` 第二问自动携带历史,追问"那上海呢"能命中上文): ```bash curl -X POST localhost:6153/llm/agent/session \ -H 'Content-Type: application/json' \ -d '{"sessionId":"demo-1","message":"我叫小明,记住我"}' curl -X POST localhost:6153/llm/agent/session \ -H 'Content-Type: application/json' \ -d '{"sessionId":"demo-1","message":"我叫什么名字?"}' ``` 事件序列示例: ``` event:delta {"text":"查一下。"} ← 中间轮 preamble 实时透传 event:tool {"round":1,"id":"call-1","name":"get_weather", "arguments":"{\"city\":\"北京\"}","result":"{...}","isError":false} event:delta {"text":"北京今天 21 度,晴"} ← 终答 event:done {"provider":"deepseek","model":"deepseek-chat", "rounds":1,"inputTokens":10,"outputTokens":5,"latencyMs":8} ``` ## 配置项 | 配置 | 默认 | 说明 | |---|---|---| | `llm.gateway.providers.*` | — | 厂商声明(base-url / api-key / models) | | `llm.gateway.routing.strategy` | `cost-first` | 路由策略(当前仅此一种) | | `llm.gateway.routing.fallback` | `true` | 失败是否自动换候选 | | `llm.gateway.routing.max-attempts` | `3` | 单次调用最大尝试数 | | `llm.gateway.streaming.idle-timeout` | `30s` | 流式相邻两行的最大间隔(0/负禁用) | | `llm.gateway.agent.max-rounds` | `8` | Agent 循环轮数上限(最小 2) | | `llm.gateway.agent.default-system-prompt` | — | `AgentClient` 便捷调用的默认 system 人设(省略则不前置 system 消息) | | `llm.gateway.memory.max-history-messages` | `40` | 会话请求携带的最近历史消息条数上限(按条截断最旧) | | `llm.gateway.memory.sessions-cap` | `1000` | 内存会话存储的会话数上限(LRU 淘汰最久未访问) | | `llm.gateway.tool-search.enabled` | `true` | 工具懒加载 + `tool_search` 检索开关(`false` 退回全量急加载) | | `llm.gateway.tool-search.local-threshold-tools` | `20` | 本地工具兜底阈值:数量超过则本地也降为懒加载(MCP 工具一律懒加载) | | `llm.gateway.tool-search.local-threshold-tokens` | `3000` | 本地工具兜底阈值:schema 估算 token(字符/4)超过则本地也降为懒加载 | | `llm.gateway.tool-search.top-k` | `5` | `tool_search` 返回与单次激活的工具数上限 | | `llm.gateway.mcp.enabled` | `true` | MCP 接入开关(`false` 忽略 servers 声明,连接器为空壳) | | `llm.gateway.mcp.servers..transport` | — | 传输形态:`stdio`(需 command)或 `http`(需 url),二者必选其一 | | `llm.gateway.mcp.servers..command` / `args` / `env` | — | stdio 传输:拉起命令与参数、注入子进程的环境变量(Windows 下 npx 需写 `npx.cmd`) | | `llm.gateway.mcp.servers..url` / `headers` | — | http 传输:Streamable HTTP 端点与自定义请求头(如 Authorization) | | `llm.gateway.mcp.tool-timeout` | `30s` | 单次 MCP 请求(握手 / 工具调用)超时 | | `llm.gateway.mcp.reconnect-cooldown` | `60s` | 服务器 DOWN 后的重连冷却窗口(过后由下一次工具调用触发重连) | | `llm.gateway.mcp.instructions-limit-chars` | `2000` | 单服务器 instructions 注入 system 后缀的截断上限 | ## 路线图 | 阶段 | 主题 | 状态 | |---|---|---| | P1 | llm-gateway 地基(领域模型 + 厂商适配 + 自动配置) | ✅ | | P2 | 路由链(四级过滤 + 兜底 + pinned 直调) | ✅ | | P3 | SSE 流式(回调契约 + 0-delta 兜底窗口) | ✅ | | P4 | 观测(事件 SPI + 日志 + 指标 + 行级超时) | ✅ | | P5 | 工具调用(api 工具模型 + 注册表 + 协议) | ✅ | | P6 | Agent 循环(while + 强收终答 + 流式 tool_calls 归并) | ✅ | | — | SDK 化重组(llm-gateway → agent-sdk + agent-spring-boot-starter) | ✅ | | P7 | 记忆(会话短期记忆:askInSession + 历史截断 + LRU 存储) | ✅ | | P8 | MCP 生态(ToolSelector SPI + tool_search + MCP 客户端) | ⏳ P8a/P8b ✅(余 P8c Skill、P8d 并行执行 + 工具观测) | SDK 化第一步已落地:核心零 Spring、`api` 为公共契约、Tool 以 bean 声明自动接入;下一步对外发布与版本自立。 ## 设计原则 - **api 包零依赖**:领域模型只有 record / enum / interface,不含 Spring 与网络——厂商无关、可移植。 - **契约先行**:流式是 `onDelta×n + 恰好一个终止事件`;工具执行永不向调用方抛异常;Agent 循环工具失败不中断。 - **TDD**:每个阶段测试全绿才提交;provider 层用内嵌 `FakeOpenAiServer` 走真实 HTTP 全链路,路由层用内存 Fake。 - **观测是内建的**:不是外挂 APM,是 SPI 事件 + 零开销发布路径。 ## 变更记录 见 [CHANGELOG.md](CHANGELOG.md)。