ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Ktor构建本地优先AI Agent:从ReAct循环到工具调用的开源实践

用Ktor构建本地优先AI Agent:从ReAct循环到工具调用的开源实践 先说结论我用 Ktor 从零写了一个本地优先的开源 AI Agent项目代号叫LanrLocal Agent Runtime。它做的事情就是把大模型、工具调用、记忆管理和任务调度全塞进一个轻量的 JVM 服务里让 Agent 在没有外部云 API 的前提下也能完成拆解目标、调用函数、检索本地资料这一整套流程。它能解决什么问题打个比方你本地跑着一个 Ollama里面装了 qwen2.5 之类的小模型Lanr 会把你的需求拆成步骤该查资料查资料、该算数算数、该读本地文件就读本地文件。整个过程里对话记录、工具执行结果、任务状态全都落在本地数据库数据不出机器。这个项目本身也是开源的我放在 GitHub 上许可证选了 Apache-2.0有兴趣的人直接 clone 下来就能跑。这篇文章适合谁看第一类是写 Kotlin/JVM 后端、想往系统里塞一个 Agent 能力的开发者第二类是天天折腾本地大模型、对数据上云这件事有顾虑的玩家。我会把从架构决策到具体实现再到开源过程中踩过的坑一次说清楚。1. 为什么是 Ktor为什么是本地优先1.1 这个 Agent 到底在解决什么问题先说清楚我做的不是一个聊天机器人。聊天只是交互外壳Lanr 的核心是一个能自主完成任务的自动化引擎。比如你可以对它说“帮我把 workspace 里所有 Markdown 文件按更新时间排序列前十个标题”它会自己去遍历目录、读文件元信息、排序最后返回结果。再比如“每天早上九点检查某个目录有没有新增图片有的话生成一份索引文件”这种定时任务它在本地就能扛下来。选择做本地优先不是为了喊口号而是因为我在实际使用中发现Agent 这种产品形态对数据自主权的要求比普通软件高得多。你让一个云端 Agent 帮你读文件、跑命令、整理资料本质上就是把最敏感的操作细节交到别人手里。本地优先的核心好处有三个一是隐私所有检索、对话、工具执行日志都不出本机二是离线可用断网之后 Agent 依然能干活三是成本可控本地 7B 模型足够承担 80% 的日常任务没有按 Token 计费的压力。这个定位决定了技术选型存储用 SQLite、推理走 Ollama、调度用协程Agent 核心不依赖任何云服务。一句话概括设计原则本地优先就像自己做菜而不是点外卖过程可控、材料可信代价是你要会做饭——也就是懂得部署模型、管理依赖、处理本地环境的坑。1.2 Ktor 比 Spring Boot 和 FastAPI 好在哪选 Ktor 之前我认真对比过 Spring Boot 和 FastAPI。Spring Boot 生态确实庞大现在还有 Spring AI 这种专门做 Agent 的模块但对我来说太重了启动要几百毫秒起步依赖一堆starter配置成本高。FastAPI 在 Python 生态里确实是 AI 开发的主流但我的核心逻辑全在 JVM 这边不希望为了一个 HTTP 外壳引入第二种技术栈。Ktor 最打动我的是它把“轻量”和“协程原生”这两件事同时做到了。它的路由 DSL 写起来非常自然内置 WebSocket 和 SSE 支持和 Coroutines 是一等公民的配合做流式输出几乎不用额外学习成本。另一个隐藏优势是 Ktor 的应用可以是嵌入式启动这意味着我既可以把 Lanr 当作一个独立服务跑也可以把它编译成 SDK 供其他 Kotlin 项目直接调用。特性KtorSpring BootFastAPI启动速度毫秒级几百毫秒到秒级秒级协程/异步原生支持需要 WebFlux 或虚拟线程依赖 asyncio体积轻量较重中等Kotlin 类型安全原生良好一般适合场景嵌入式、网关、Agent 外壳企业级 CRUDPython AI 生态实际跑下来Ktor 的 Socket 吞吐和连接管理能力远比我想象的扎实。做 WebSocket 流式输出的时候Ktor 的 WebSocket 会话管理与协程 Job 绑定非常顺手客户端断线能立刻感知不会留下僵尸任务。1.3 本地优先的具体设计决策本地优先不是“不用网”而是一套有取舍的设计约束。我在立项时列了几条硬性原则后续所有功能都围绕它们展开。第一模型接口可插拔。Lanr 定义一个ChatModel接口本地默认实现是 Ollama但你也可以配一个 OpenAI 兼容的远端模型地址。这样后续想接云端大模型做对比只需要换配置文件核心 Agent 循环一行不用改。第二数据格式全部开放。会话数据、工具调用记录、任务队列都存储在 SQLite 里不用任何私有二进制格式。用户随时可以用 SQLite 浏览器打开数据库看到 Agent 每一步在想什么、做了什么。透明是本地优先最大的底气。第三工具权限最小化。本地 Agent 一旦有了执行能力安全问题就成了首要考量。Lanr 的工具系统做了两层控制一是工具本身只暴露最小必要能力二是每个高危工具默认要求人工确认。比如执行 shell 命令工具描述里会明确限制在白名单命令范围内并且有输出长度上限。2. 核心架构拆解Agent 循环、工具调用与记忆系统2.1 Agent 最小闭环ReAct 循环是怎么设计的Lanr 的核心是一个 ReAct 风格的循环简单说就是“推理-行动-观察”的循环。工程上的实现比论文更朴素每一步就做四件事把系统提示词、历史消息、工具描述一起发给大模型。模型返回两种结果之一要么直接给出最终回答要么请求调用某个工具并附带 JSON 格式的参数。如果请求调用工具Agent 就执行工具把执行结果作为一条 tool 类型消息回填给模型。回到第一步继续循环直到模型给出最终回答或达到最大迭代次数。这个循环看起来简单但真正工程化之后暗坑不少。最大的坑是终止条件。模型面对某些任务会陷入死循环工具返回结果不理想它就反复重试永远不给最终答案。所以我在循环里强制设了最大迭代次数默认 8 次超过就不再调用模型而是把当前已有的工具结果汇总返回并告诉上层“这个任务可能需要你改一下指令”。伪代码大约是这个样子suspend fun run(userInput: String): String { val messages mutableListOf( Message.system(SYSTEM_PROMPT), Message.user(userInput) ) repeat(MAX_ITERATIONS) { val response model.chat(messages, tools) if (response.toolCalls.isEmpty()) { return response.content.orEmpty() } messages Message.assistantToolCalls(response.toolCalls) for (call in response.toolCalls) { val result toolRegistry.execute(call.name, call.arguments) messages Message.tool(call.id, result) } } return 任务过于复杂已达最大迭代次数请拆分后重试。 }这里有一个容易忽略的细节工具执行结果不能无脑塞给模型。如果工具有时返回几 MB 的文本模型上下文会被瞬间占满然后开始“失忆”。我做了结果截断策略默认只保留前 2000 字符同时在截断处标注“结果过长已省略如需完整数据请用其他工具获取”让模型有自知之明。2.2 Function Calling 在 Ktor 端的建模现在各大模型都支持 Function Calling但工程端如何把工具定义变成模型能读懂的 Schema才是真正拉开体验差距的地方。Lanr 的工具描述不是手写 JSON 字符串而是通过 Kotlin 类型自动生成的。每个工具实现一个接口interface AgentTool { val name: String val description: String val parameters: JsonObject suspend fun execute(args: JsonObject): JsonElement }parameters用一个简单的 DSL 构建比如定义一个获取天气的工具object GetWeatherTool : AgentTool { override val name get_weather override val description 获取指定城市的天气情况 override val parameters buildJsonObject { put(type, object) put(properties, buildJsonObject { put(city, buildJsonObject { put(type, string) put(description, 城市名称例如北京) put(example, 北京) }) }) put(required, buildJsonArray { add(city) }) } override suspend fun execute(args: JsonObject): JsonElement { val city args[city]?.jsonPrimitive?.content ?: return jsonElement(缺少参数: city) return jsonElement(北京今天晴气温 -2°C 到 8°C) } }发送给模型的时候再把这套结构转成 OpenAI 兼容的tools格式。这里我要重点强调一个经验工具描述里一定要给示例值。模型对“参数格式”的理解方式跟人不一样它更倾向于从示例中归纳规律。你写“日期格式 YYYY-MM-DD”它可能还是会传yyyy-mm-dd但如果你写明“例如2026-01-15”它能非常准确地照抄。所以我在 Lanr 的 DSL 里专门加了一个example字段生成的 Schema 里也保留它实测下来工具调用参数错误率能降一半以上。2.3 记忆与本地存储Agent 如果没有记忆每次对话都是陌生人。Lanr 的记忆系统分三个层次每一层都落在本地 SQLite。第一层是短期记忆直接保留最近 N 条消息原始内容。这部分最简单但也最有效模型对最近几轮上下文的感知最准确。第二层是向量记忆用来做语义检索。用户的问题先转成 embedding然后在历史消息和知识片段里做相似度检索把 topK 相关片段拼进上下文。Embedding 模型同样走本地我用的 Ollama 里的 nomic-embed-text 和 bge-m3都是可以在普通 CPU 上跑的轻量模型。向量存储选了 sqlite-vec这是个 SQLite 扩展零额外服务单文件备份完美贴合本地优先的原则。第三层是任务结果缓存。工具调用如果返回了一些可复用的信息比如文件列表、查询结果会按语义相关性做短时缓存避免 Agent 反复调用同一个工具浪费时间和 Token。存储层我用 Exposed 框架操作 SQLite因为 JetBrains 的这套 DSL 写起来接近原生 SQL类型安全又比手写 JDBC 舒服。唯一要注意的是 SQLite 的并发写问题我在初始化时开了 WAL 模式并且连接池大小控制在 4 以内实测稳定。3. 实操过程从空项目到一个能跑起来的 Agent3.1 环境准备与项目结构我不会一上来就贴一堆配置先把我自己的环境亮出来方便你对照一台 32G 内存的 Mac mini 跑 Agent 服务另一台 16G 内存的 Linux 主机专门跑 Ollama模型用的 qwen2.5:7b。技术栈版本是JDK 21、Kotlin 2.0、Ktor 3.x、Gradle 8.10构建脚本用 Kotlin DSL。这些版本组合起来很顺没有遇到兼容性天坑。项目结构我拆了四个 Gradle 子模块一开始就为开源之后的协作留好边界lanr/ ├── core/ // Agent 引擎、模型抽象、记忆管理 ├── tools/ // 内置工具集 ├── server/ // Ktor HTTP/WebSocket 层 └── cli/ // 命令行入口为什么这样拆因为开源之后别人如果只想在自己项目里嵌入 Agent 能力只需要依赖core和toolsserver对他来说可有可无。模块边界清楚协作的贡献者也不会为了加一个工具就得理解整个 Web 层。3.2 接入 Ollama完成第一个带工具调用的对话先把 Ollama 环境准备好。在跑 Ollama 的机器上执行两条命令ollama pull qwen2.5:7b ollama pull nomic-embed-text然后确认本机 Ollama 服务在监听 11434 端口。Lanr 走的是 Ollama 的 OpenAI 兼容端点所以不需要额外装插件直接配置 base url 指向http://localhost:11434/v1就行。用 Ktor Client 发一个带工具的聊天请求核心请求体长这样{ model: qwen2.5:7b, messages: [ { role: system, content: 你是一个本地助手只使用提供的工具完成任务 }, { role: user, content: 帮我算一下 23 乘以 17然后告诉我现在几点了 } ], tools: [ { type: function, function: { name: calculator, description: 计算四则运算表达式例如 23*17, parameters: { type: object, properties: { expression: { type: string, description: 数学表达式 } }, required: [expression] } } }, { type: function, function: { name: get_current_time, description: 获取当前时间, parameters: { type: object, properties: {} } } } ], tool_choice: auto }模型第一次返回时不会直接给答案而是给一个tool_calls数组先调用calculator传{expression: 23*17}再调用get_current_time。Agent 依次执行工具后把结果拼成消息再发一轮模型才会输出“23 乘以 17 等于 391当前时间是下午 2 点 35 分”这样的最终答案。第一次跑通这个闭环的时候我挺兴奋的因为这就是 Agent 最核心的“骨架”。后面所有功能——记忆、RAG、任务队列——都是在这个骨架上长出来的。3.3 把 Agent 包成服务REST API WebSocket 流式输出交互体验上纯请求响应模式对 Agent 来说太便秘了。本地小模型生成速度本就不快一个稍复杂的任务可能要几十秒用户盯着空白页面什么都做不了。所以 Lanr 的服务层同时提供了两种模式。REST 模式适合程序化调用POST /api/chat // 发消息返回完整结果 GET /api/tools // 列出当前注册的所有工具 GET /api/sessions // 查看会话列表WebSocket 模式适合人类用户交互我在ws/chat路径上接收消息然后按 token 分批把生成内容推给客户端。Ktor 的 WebSocket 支持在这里非常顺手fun Route.chatWs() { webSocket(/ws/chat) { val sessionId call.request.queryParameters[sessionId] val agent agentManager.getOrCreate(sessionId) for (frame in incoming) { if (frame is Frame.Text) { val result agent.streamChat(frame.readText()) result.collect { chunk - send(Frame.Text(Json.encodeToString(chunk))) } } } } }并发控制上我给每个 session 绑定了一个独立的 Agent 实例并用单线程调度保证消息不乱序。客户端断线时协程 Job 由 Ktor 的会话生命周期自动取消任务状态会写回 SQLite用户下次带上同一个sessionId回来可以继续追问历史上下文。这里有一个实操心得流式输出对工具调用的状态展示特别重要。我在推送的消息里封装了类型比如token、tool_start、tool_end、final。前端拿到tool_end时可以展示“正在调用 weather 工具...”这样的中间状态用户体验完全不黑盒。4. 工具生态与扩展点如何做出真正有用的 Agent4.1 内置工具集合设计一个 Agent 能不能干活七成看工具设计。我在计划内置工具时坚持一个原则工具职责越单一模型用错的概率越低。当前 Lanr 内置了这些工具工具名用途关键参数风险控制fetch_url抓取网页并提取正文url、max_length只允许 http/https限制跳转次数正文截断local_search在指定目录做关键词搜索query、root_dir限制在 workspace 目录内run_shell执行白名单命令command只允许 ls/cat/find 等超时 10 秒输出截断calculator安全计算四则运算expression自研表达式解析器不调用系统 evaldatetime获取时间、计算日期差timezone、operation无副作用read_workspace_file读取工作区内文件path、offset、limit防路径穿越编码检测单次最多 5000 字符拿fetch_url来说一开始我以为这个工具很普通但实现时发现坑特别多。网页抓下来不是纯文本不清理的话模型会被一堆 script 和 style 标签里的噪音淹没。我用 Jsoup 解析 HTML过滤脚本和样式标签再抽取article、main或长段落作为正文最后按max_length截断。实际测试时有些网站的编码不是 UTF-8所以还要做编码探测。这些都是文档里不会写、但真实使用中一定会碰到的问题。4.2 自定义工具的接入方式内置工具是地基但每个用户都有自己的场景所以工具系统一定要开放。Lanr 用的是 ServiceLoader 机制开发者只需要实现AgentTool接口然后在META-INF/services里注册实现类重启服务后工具自动出现在工具列表里Agent 也会在下一次调用时自动识别它。举个例子假设你想加一个获取系统信息的工具class SystemInfoTool : AgentTool { override val name system_info override val description 获取当前机器的 CPU、内存和磁盘使用情况 override val parameters buildJsonObject { put(type, object) } override suspend fun execute(args: JsonObject): JsonElement { val os System.getProperty(os.name) val memory Runtime.getRuntime().totalMemory() / 1024 / 1024 return jsonElement(操作系统: $os, JVM 可用内存: ${memory}MB) } }看起来简单但这里有一个关键点工具参数的 Schema 必须和实际execute里解析的参数严格对齐。模型是非常较真的执行者你 Schema 里说format是 string结果实现时用数字去解析模型传对了你也报错它就会怀疑人生。我见过很多半成品 Agent 死在这上面所以 Lanr 在工具注册时会做一次 Schema 自检参数类型对不上直接启动报错而不是运行时才爆。4.3 多模型切换与供应商抽象Lanr 的模型抽象是这样一个接口interface ChatModel { suspend fun chat( messages: ListMessage, tools: ListToolSpec, stream: Boolean ): ModelResponse }默认实现是OllamaModel走 OpenAI 兼容端点另一个重点是MockModel它在测试里返回固定输出不需要拉起 Ollama 也能跑 CI。这个抽象带来的好处是本地模型跑得不好时我可以随时把同一个请求切换到云端更大的模型做对比而 Agent 循环一行不改——无论是 qwen2.5:7b 还是云端 gpt-4oAgent 都只认工具和循环不认具体模型。模型参数和 Agent 行为联动这件事越早重视越好。我把温度参数默认调到 0.2因为工具调用场景要求确定性太高的温度会让模型发挥“创造力”然后生成一个不存在的工具名。top_p设在 0.8requestTimeout设 60 秒因为本地 7B 模型在 CPU 上生成一次完整回复可能超过 30 秒超时太短会导致误杀。这些参数我都暴露在配置文件里用户可以根据自己的模型微调。5. 开源过程中的那些坑5.1 可复现性让 README 里的 Quick Start 真的能跑开源最大的坑不是代码而是别人 clone 下来之后跑不起来。我在早期犯过错误README 里写“先装好 Ollama”但没写版本结果用户装的 Ollama 版本不支持某个模型 tag打开就报错。后来我做了三件事。第一用 Gradle Version Cataloglibs.versions.toml把所有依赖版本锁死包括 Kotlin、Ktor、Exposed 和序列化库杜绝“我这边能跑你那边不行”的问题。第二提供了一个 Docker Compose 文件一键拉起 Ollama Lanr 两个容器连模型 tag 都写死用户在容器里永远拿到一致的环境。第三CI 里除了跑 JVM 端的单元测试我还用MockModel跑了一套完整的 Agent 集成测试——不依赖真实 Ollama启动快还能精确断言工具调用顺序。这套组合下来现在新用户 clone 项目后最快 5 分钟就能看到 Agent 回话。我一直觉得开源项目的“第一印象”不是代码写得多漂亮而是 Quick Start 是不是三分钟能跑起来。5.2 文档与许可证越早做越省心开源不只是把代码挂到 GitHub 上就完了。Lanr 的 README 我写了五版才顺眼结构固定为项目简介、Quick Start、架构说明、工具列表、FAQ。很多人不愿意写文档但我的体会是文档写得越清楚提问的 issue 越少省下来的时间远超写文档的投入。License 我选了 Apache-2.0第三方的依赖许可证也做了检查用的是 Gradle 的 License Check 插件避免发布之后才发现某依赖的许可协议不兼容。开源协作方面我配了 issue 模板和贡献指南新来的贡献者可以直接照着模板提 PR。5.3 常见问题排查表整理几个我实际收到最多、也最典型的提问做成速查表给你现象排查思路解决方案Ollama 连接总是失败检查 Ollama 服务是否在监听http://localhost:11434设置OLLAMA_HOST0.0.0.0跨机器则放行防火墙Lanr 的 base url 不能带/v1后缀时改配置模型输出 JSON 解析失败模型经常把工具调用包在 markdown 代码块里提取第一个{到最后一个}并做宽容解析失败时把报错回填给模型让它自我纠正Agent 进入死循环工具结果不满足模型预期导致反复重试强制最大迭代次数设为 8工具结果按长度截断并在回填消息中提示“内容已省略”内存占用过高多会话并发时每 session 一个 Agent 实例限制最大并发 sessionJVM 设-Xmx长时间不活跃的 session 要从内存中卸载并保留在 SQLiteWebSocket 断线后任务丢失客户端断开后协程被取消任务没落库每个会话在启动任务时先写任务记录断线后客户端带 sessionId 重连可查询任务状态并续接结果工具参数总是传错工具描述太抽象模型不理解格式细节在参数描述里增加 example 字段给出具体示例值模型会照抄示例而不是自行发挥最后再分享一点经验我起初以为 Agent 的难点在怎么跟大模型对话写完才发现真正的难点在工具、记忆和状态管理。Ktor 在这里的角色更像一个底盘把 HTTP、WebSocket、协程这些杂活全接走了让我能把注意力放在 Agent 循环本身。另外本地优先最容易被低估的价值其实是确定性。用云端大模型的时候你永远不知道服务什么时候变更、字段什么时候废弃、数据被拿去干了什么。本地跑一个笨一点的小模型好处是每一次行为都有日志、每一个请求都能审计。这种可控感对 Agent 这种自主执行型的应用实在太重要了。如果你也想动手做类似的 Agent我的建议是别一开始就追求复杂的编排。先在 Ktor 里搭一个能调用两三个工具的最小闭环跑通了再加记忆再加 RAG再加定时任务。这个项目最大的收获不是跑了多大的模型而是把 Agent 的工程链路完整走了一遍。选对底座会让这条路好走很多。
返回列表