☰
用Ktor构建本地优先AI Agent:协程、流式与工具调用实践
2026/11/2 12:36:17 网站建设 项目流程

“你的 Agent 为什么不用 Python 写?”这是我开源之后被问得最多的一句话。这两年聊 AI Agent,几乎所有人默认就是 FastAPI + LangChain + LangGraph,或者 Node 那边的 tool-use 工作流,再非主流一点,也是 Rust 写的高性能 runtime。

我不是为了标新立异才选 Ktor。我的需求从一开始就把技术选型逼向了一个不太一样的落点:我要一个本地优先的 AI Agent,断网也能跑,所有对话记录、知识库、工具调用日志都留在本机,模型可以让用户自己接本地推理服务或者任意云端 API。这个“本地优先”的前提,直接决定了我不能用那些默认把一切流量都绕到公网网关的框架。

最后我选择了 Ktor,用一个月左右的时间把项目从零搭起来,形成了一个支持流式对话、工具调用、本地知识库和长期记忆的 Agent 服务,并且已经把核心代码开源。这篇文章就是把我在这个过程中的技术决策、模块设计、并发处理和踩坑经历完整复盘一遍,给同样想尝试 Kotlin/Ktor 做 Agent 的人一个可参考的路线。

1. 为什么挑 Ktor:本地优先这个约束改变了太多事

1.1 本地优先到底在优先什么

我理解的“本地优先”,不是简单地把进程跑在 localhost 上,而是三个硬性要求:

第一是数据不出机器。用户的文档、聊天记录、工具执行结果,默认只存在于本机,只有用户明确开启某个外部服务时才允许出网。这意味着我不仅要控制网络请求,还要在设计存储时就默认“本地落盘为一等公民”,而不是“云端同步”的降级方案。

第二是断网可用。用户可能在飞机上、在会议室里、在完全没有公网的办公内网中。Agent 至少要能完成登录、加载知识库、和本地模型对话、执行本地工具这一整条链路。

第三是资源可控。本地机器不是云端 GPU 服务器,内存、CPU、带宽都有限。Agent 的运行必须轻量,不能起一个动不动就占 2G 内存的 Python 解释器加一堆依赖做常驻服务。

这三个约束决定了,我需要的不是一个“AI 框架”,而是一个适合 AI 业务逻辑的异步服务框架。Ktor 恰好在这个点上满足要求:它足够轻、足够底层、协程模型和流式 IO 是原生能力,而且用 Kotlin 写出来的业务代码在重构时非常顺手。

1.2 Ktor 和 FastAPI 方案的实际差异

我不是说 FastAPI 不好,它对很多服务端场景依然是最优解。但在这个项目里,Ktor 有几个实实在在的优势:

  • 协程是语言级能力。Agent 的完整运行过程是一个“思考 -> 调用工具 -> 观察结果 -> 再思考”的长循环,中间充斥着 IO 等待。Ktor 的协程让这段循环写起来像顺序代码,同时取消、超时、并发控制都有标准 API。Python 里 async/await 虽然也能写,但和 Kotlin 协程的结构化并发一比,后者在“任务取消”这个关键场景上要优雅太多。

  • 单 JAR 分发,部署是真的省心。Ktor 项目用 Gradle 打成一个 Fat JAR,几十兆,扔到任何有 JVM 的机器上就是java -jar一条命令。Python 项目光是虚拟环境、依赖版本、Python 解释器这三点,就够在用户机器上踩掉半条命。对“本地优先”这个场景来说,低分发成本就是命根子。

  • 类型安全让 Agent 的状态机不容易烂。Agent 里最难的往往不是 Prompt,而是状态。当前轮次、工具调用记录、工具入参、上下文窗口满了之后该丢哪些消息……这些状态如果用 dict 到处传,中期必乱。Kotlin 的 data class 和 sealed class 能把状态约束在编译期,我后面会详细说。

  • Ktor 客户端和服务端是同一套 API。本地 Agent 经常要调用内网的其他 HTTP 服务,Ktor Client 和 Server 都是基于同一套协程 IO,写起来心智负担很小。

1.3 协程模型如何简化“托管长任务”

一个 AI Agent 请求,和普通 HTTP 请求最大的不同是:它可能跑几十秒甚至几分钟。常规 Web 框架里,这种长任务要么丢给消息队列后台跑,要么就得好好设计超时和取消。

在 Ktor 里,每个 Agent 对话请求就是一个独立的协程。这个协程里,大模型流式输出也好,工具等待也好,都是 suspend 函数,随时可以被取消。用户如果发现 Agent 跑偏了,点一个“停止”,前端发一个取消请求,我直接cancel()对应的协程,底层 socket 立即释放,线程不会被占用。

这种“一个任务一个协程”的模型,让我在写 Agent 并发控制时非常舒服。后面我会专门展开。

2. 项目骨架设计:模块拆解与一次请求的完整旅程

2.1 模块划分:不追求微服务,但必须追求边界清晰

本地优先的单机服务不需要微服务,但代码绝对不能全塞在一个包里。我的 Gradle 模块结构是这样分的:

agent-core // 领域模型、Agent循环、工具抽象 agent-store // 本地持久化、向量检索、记忆管理 agent-server // Ktor HTTP服务、SSE流、API路由 agent-tools // 内置工具集:日历、文件、HTTP、SQLite查询等 agent-app // 打包入口、依赖装配

每一层依赖方向都是单向的:agent-server依赖agent-core,agent-tools依赖agent-core,但agent-core不依赖任何 HTTP 和数据库细节。

这么做的好处是,Agent 的核心逻辑可以在没有网络的情况下跑单元测试,我只需要 mock 一个模型响应即可。本地优先的项目最怕的就是“核心逻辑绑死在 HTTP 层”,想换个前端(比如以后做桌面端)就得重构。

2.2 核心数据模型:Kotlin 的 sealed class 立了大功

Agent 的运行时状态,我是用 sealed class 建模的:

sealed interface AgentEvent { data class Token(val text: String) : AgentEvent data class ToolStart(val name: String, val args: String) : AgentEvent data class ToolEnd(val name: String, val result: String) : AgentEvent data class AskHuman(val question: String, val requestId: String) : AgentEvent data class Done(val summary: String? = null) : AgentEvent }

任何一步 Agent 运行过程,都会被转成这个事件流,然后通过 Ktor 的 SSE 推给前端。

这个设计的核心原因是:把“Agent 内部状态”和“展示层协议”完全解耦。前端不管你是用了 LangChain 式的 ReAct 循环,还是 Model Context Protocol,它只认事件。后续如果我要同时支持 Web 端、桌面端、甚至是自定义的脚本调用,都只要消化同一套事件流就行。

另一个重要模型是工具调用的规范描述:

data class ToolSpec( val name: String, val description: String, val parameters: JsonObject, // JSON Schema val executor: suspend (JsonObject, ExecutionContext) -> ToolResult )

工具的入参统一用 JSON Schema 描述,大模型在函数调用时按照 Schema 生成入参 JSON,我在本端再做一层校验,不合格的重试一次。这个机制和开源的 function calling 协议是兼容的。

2.3 一次请求的完整旅程

从用户按下发送键开始,整个链路是这样的:

  1. 前端发POST /v1/chat/messages,携带用户输入和相关会话 ID。
  2. Ktor 路由先做鉴权和参数校验,然后从agent-store加载该会话的历史消息和记忆摘要。
  3. 把历史消息 + 用户输入拼成给模型的 Prompt,启动一个 Agent 协程。
  4. Agent 循环开始:调用模型生成推理结果和可能的工具调用。
  5. 如果模型要调用工具,就通过agent-tools执行,然后把工具结果追加到上下文里,继续回模型。
  6. 整个过程产生的AgentEvent消息,通过 SSE 通道实时推给前端。
  7. 循环结束之后,把最终答复、工具执行摘要、消耗的 token 数都持久化到本地。

这个链路看起来和大多数 Agent 一样,但落到 Ktor 上有一个好处:第 4 和第 6 步本质上都是协程里的普通调用,我可以非常轻松地给 Agent 循环加“看门狗”逻辑,比如单次工具调用超过 5 分钟就自动中止,整个 Agent 会话超过 20 分钟就强制结束。

3. 流式对话的工程实践:SSE、WebSocket 和并发控制

3.1 为什么 Agent 必须流式输出

如果你用过非流式的 Agent,你大概经历过那种“转了十秒,啥反应都没有”的焦虑。人在等待不确定任务时,对延迟的容忍度极低,需要持续反馈。我从第一版就决定,所有的对话输出都必须是流式的。

而且 Agent 的流式反馈比纯聊天更复杂。它不仅要流式返回文字 token,还要实时播报“正在调用工具”“工具执行完成”“检索到 3 条相关知识”这些中间状态。这时候事件流就不仅仅是体验优化,而是让用户相信 Agent 没有卡死的关键手段。

3.2 基于 Ktor 的 SSE 实现

Ktor 的 SSE 插件用起来比大多数框架都舒服。服务端这样写:

get("/v1/chat/events/{sessionId}") { val sessionId = call.parameters["sessionId"]!! val flow = agentHub.subscribe(sessionId) call.respondTextWriter(contentType = ContentType.Text.EventStream) { flow.collect { event -> val json = eventMapper.encodeToString(event) write("data: $json\n\n") flush() } } }

agentHub是一个内存中的事件总线,每个会话对应一个MutableSharedFlow。Agent 循环往 Flow 里发射事件,SSE 端点的协程从 Flow 里转发。

这里有一个非常重要的细节:为什么用respondTextWriter而不是respond+ByteArrayContent?因为 Ktor 的respondTextWriter返回的是一个Writer,我可以边写边 flush,真正实现逐字输出。如果一次性响应用完再 flush,前端拿到的就不是流式效果。

前端收到 SSE 之后,按照data:块解析事件类型,Token就直接追加到对话框,ToolStart就显示一个工具运行的卡片。整个过程肉眼看不出来有网络缓冲,体验非常好。

3.3 并发控制:本地 Agent 拿什么扛高并发

搜索热词里有人问“AI Agent 怎么扛并发”,这确实是所有做 Agent 服务的人绕不开的问题。云端方案可以无限水平扩展,但本地优先的服务,机器就是一台普通电脑,得靠算法层面做并发收敛。

我做了三层控制。

第一层是信号量限流。模型调用是本地 Agent 最贵的资源,我维护了一个Semaphore(4),同一时间最多 4 个 Agent 协程同时去调用模型接口。超过 4 个就排队,而不是无限创建协程把内存吃爆。

第二层是会话级锁。同一个会话的历史消息是共享状态,如果同一个会话同时来两个请求,会互相污染上下文。我用Mutex保证同一会话的 Agent 循环是串行的。

第三层是请求级别的超时。每一个 Agent 请求都挂了协程超时,模型调用给 60 秒,工具调用给 300 秒,整个 Agent 会话给最长 20 分钟。超时的协程直接取消,底层资源立即回收。这一步看着简单,但在 Python 的 async 世界里做起来确实没有 Kotlin 干净——协程取消是彻底的结构化取消,不是给一个“停止标志”然后等它自己响应。

实际测试中,在 8G 内存的 MacBook 上,这个本地 Agent 能稳定跑 20 个并发会话,每个会话各自持有独立的上下文和工具调用状态,内存占用大概在 600M 左右。对一个本地服务来说我认为是完全够用的。

4. 本地知识库与记忆系统:向量化落盘的细节

4.1 本地优先的向量存储选型

Agent 如果只有聊天能力,那就是个带 UI 的模型壳。真正让它“懂你”的,是知识库和记忆。但这块在本地优先约束下也带了特殊难度:我不能直接调用云端的向量数据库服务,甚至很多开源向量库也需要自己起一个服务进程。

我最后采用的方案是:本地 embedding 模型 + 轻量级向量存储。

embedding 用的是一百多 MB 的本地模型,用 ONNX Runtime 在 JVM 上跑。虽然启动时加载比较慢,但在问答过程中延迟很低。向量存储则直接落在一个 SQLite 文件里,配合简单的暴力检索。单个用户的知识库规模通常在几千到几万条文档块以内,暴力检索完全够用,还省去了索引维护的复杂度。

有人可能觉得,不做 HNSW 索引会不会太原始了?我的观点是:本地 Agent 的检索规模天花板很低,相比检索性能,检索质量和存储可靠性重要得多。SQLite 文件的备份、迁移、都能用最朴素的文件操作完成,这对本地优先来说是巨大的便利。

4.2 文档分块与检索召回

知识库要想有用,分块策略比向量模型还关键。我用的是“层级分块”的思路:

  • 首先按 Markdown 标题把文档切成大块,每一块对应一个章节。
  • 如果大块超过 800 token,再按段落切,但保留父章节的标题作为前序。
  • 每个分块写入向量库时,额外保存来源文件、章节路径、原始文本。

检索的时候,不是简单返回 top-5 个块,而是把命中的块按照“来自同一章节”做聚合,合并后返回给模型。这样模型能拿到更完整的上下文,而不是五段互相孤立的碎片。

这个细节非常重要,因为大模型的阅读能力其实比我们想象中弱,给它 5 个断裂的片段,它很难拼出完整的逻辑。聚合同一章节的命中块之后,回答质量提升会非常明显。

4.3 Agent 的长期记忆:不是所有消息都该永久存

“本地优先”最危险的一点,就是以为本地存储免费,于是什么都往里面塞。结果就是会话记录堆积如山,向量库变成垃圾场,检索质量持续下降。

我做的记忆系统分三层:

短期记忆:当前会话的消息列表,在 Agent 运行时放进上下文窗口。

中期记忆:每个会话结束后,我会让模型生成一段不超过 200 字的会话摘要,比如“用户让我调研了本地大模型部署方案,重点关注内存占用和推理速度,结论是 llama.cpp 最适合当前设备”。这段摘要入库,下次用户再聊类似话题时,Agent 会先检索历史摘要来联想。

长期记忆:用户主动标记为“记住”的信息,比如偏好设定、常用工具、特定项目的背景知识。这类信息优先级最高,每次对话都会注入。

这个分级设计的好处是,本地向量库里真正高价值的数据量被控制在很低的范围。我的目标是知识库加记忆总量控制在 50M 以下,这样备份整个 Agent 数据目录就是一条 rsync 或者复制粘贴的事情,完全符合本地优先的“随时可迁移”理念。

5. 让 Agent 真的下地干活:工具调用循环的工程实现

5.1 从“聊天机器人”到“干活 Agent”,核心是工具循环

如果对话没有工具调用,Agent 就永远是个高级聊天窗。我的项目里内置了十几个工具:执行本地命令、读取文件、查 SQLite、调内网 API、创建日历提醒、发邮件草稿等等。

工具循环我实现成了 ReAct 的变体:模型输出里如果带了工具调用意图,就停止生成,去执行工具,然后把结果拼回上下文,继续让模型生成。整个过程用一个 while 循环驱动:

var iterations = 0 while (iterations < maxIterations) { val response = model.generate(messages) val toolCalls = response.toolCalls if (toolCalls.isEmpty()) { emit(Token(response.content)) break } for (call in toolCalls) { val result = toolExecutor.execute(call) messages += ToolResultMessage(call.id, result) emit(ToolEnd(call.name, result.summary)) } iterations++ }

这个循环本身很简单,真正复杂的是边界控制。比如:模型同一个工具反复调用不退出怎么办?工具结果非常大把窗口撑爆怎么办?所以我引入了maxIterations和maxToolResultLength,工具结果默认截断到 2000 字,避免上下文被一次日志输出撑爆。

5.2 类型安全的工具注册机制

工具注册用 Kotlin 写时,最爽的一点是类型安全。每个工具的描述包含 JSON Schema,但这个 Schema 不是手写的,而是从 Kotlin 函数签名自动推导出来的:

@Tool("查询用户数据库中的联系人") fun searchContacts( @ToolParam("关键词,支持模糊搜索") keyword: String, @ToolParam("返回数量上限") limit: Int = 20 ): List<Contact> { ... }

通过注解和 Kotlin 反射,我在项目启动时就能拿到函数名、参数名、参数类型、注释说明,自动生成完整的 JSON Schema。模型调用工具时,参数 JSON 反序列化后再交给这个函数执行。

这种方式比纯手写 JSON Schema 强太多了——手写 Schema 在工具参数多的时候极易出错,而 Kotlin 的签名本身就是 Schema 的来源,永远不会不一致。

5.3 错误兜底与人类确认

工具调用不可能一次成功。我做了两个层次的兜底。

第一是工具执行异常重试。工具抛异常时,我会把异常信息反馈给模型,让模型尝试修改参数后重试一次。比如用户说“打开陈晨的简历”,模型可能不知道文件名精确是什么,第一次调search_files("陈晨简历")没找到,工具会返回“当前目录下可用文件名列表”,模型看到列表后修正调用open_file("陈晨_简历_2025.pdf"),这一来一回通常能解决问题。

第二是危险操作人工确认。执行本地命令、删除文件、发外部请求这类有副作用的工具,我在设计上默认要走一次确认。这个确认不是强制阻断工具调用,而是通过之前提到的AskHuman事件,让前端弹一个确认框,用户点击之后 Agent 才继续执行。

这个机制很幸运,因为本地优先的 Agent 能力越强,权限控制就得越严。如果没有人工确认这一层,一个无意的 Prompt 注入可能就会让 Agent 执行危险命令,这是我不愿意承担的。

6. 开源一个本地优先 Agent,我的一些真实体会

6.1 开源协议的选择和“本地优先”带来的红利

项目开源时最纠结的是协议选择。我最后选了 AGPL-3.0,而不是 MIT 或者 Apache-2.0。

原因很简单:本地优先的项目代码一旦被闭源公司盯上,对方完全可以在自己公司内网搭一套服务,用你的代码但不对社区做任何贡献。“本地优先”本身就能规避云端 SaaS 那种“拿开源代码改了直接商用”的路子——因为服务部署在用户自己的机器上,如果你改了代码但不开放,用户和社区很难信任你。AGPL 让这种闭源内网化的使用方式必须开源派生代码,能保护项目的长期社区价值。

开源之后我发现一个“本地优先”独有的好处:不需要创始人去操心服务器费用和日志合规。用户跑自己的 Agent,数据都在自己的硬盘上,你作为开源作者是收集不到任何用户数据的。这也省掉了很多法律和隐私上的麻烦。

6.2 用户反馈里最典型的一类问题

开源以后,收到最多的 issue 不是 Agent 效果不好,而是环境相关的问题:用户 Java 版本不一致、模型 API 的配置格式不同、本地模型服务启动失败。这些坑其实和技术设计无关,而是本地优先应用天然要面对的碎片化环境挑战。

我的对策是做了两点:一是把所有配置项收敛到一个agent.conf文件里,每个字段都有默认值和注释;二是做了一遍环境自检,启动时自动检查 Java 版本、模型服务是否可达、必要目录是否可写,给用户明确的错误提示,而不是一个晦涩的异常栈。

这一点我想特别提醒所有做开源本地项目的人:本地应用的成功,往往取决于对用户机器环境的容忍度。你要默认用户的环境不标准,然后尽量把你的环境假设缩小到零。

6.3 接下来想做的改进方向

目前的版本已经支持了模型流式对话、本地知识库、工具调用、会话记忆,但离我理想中的“本地优先 AI 助手”还有不少距离。

第一个方向是给 Agent 加一个真正的事件溯源机制。现在会话记录存的是最终结果,下一步我希望把AgentEvent全量存储下来,让任何一次对话都可以完整回放,用户能看清楚 Agent 是经过怎样的思考链条得出答案的。

第二个方向是把工具生态做成可在配置中动态扩展的模式。现在内置工具是编译期的,加入新工具要改代码重新打包。下一步想做成配置驱动,用户放一个写好的类或者描述文件,重启就能用上新工具。

最后一个方向,也是我一直想做的:把 Ktor Client 的能力也利用起来,让 Agent 在用户授权后可以主动访问本机局域网内的家庭 NAS、智能家居网关。这才是“本地优先”的终极价值——它不是孤立在机器里的聊天机器人,而是你家或者你办公室内网里的一个数字员工。它离你的数据最近,所以它也该成为最懂你的那个 Agent。

我从这个项目里得到的最深体会是,技术选型从来不是比拼哪个框架名气大,而是看它和你定义的约束是否匹配。如果你也想做一个本地优先的 Agent,我的建议是从最小闭环开始:先把流式对话和工具调用跑通,再慢慢加知识库和记忆。Ktor 在中间的每一步都能给你省下不少力气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询