说句实话,最开始冒出“用 Ktor 写一个本地优先的开源 AI Agent”这个念头的时候,我自己都觉得有点“冷门”。毕竟圈子里的 AI Agent 项目,十有八九是用 Python 的 FastAPI、LangChain 那套生态写的,跑在云端 Docker 里,对外暴露一个 HTTP 接口,再配个向量库,完事。但做了一段时间之后,我发现这条路对我自己的使用场景并不合适:我需要在没有外网的环境里跑推理,需要把对话记录和 Agent 状态完整留在本机,还需要一个能长期启动、不会动不动 OOM 的常驻进程。于是我把目光转向了 JVM 生态里的 Ktor,用 Kotlin + 协程 + SQLite 重写了整个 Agent 核心,并把项目开源出来了。
这篇文章想把我在这个过程中真正做过的事情、踩过的坑、以及“为什么这么选型”的逻辑讲清楚。它不是一份文档翻译,也不是架构图堆砌,而是一个从 0 到 1 搭建 AI Agent 的实战记录。无论你是想从零开始写自己的 agent,还是在纠结 Agent 怎么扛并发、怎么设计工具调用、怎么让模型在本地稳定服务,这篇文章应该都能给你一些可参考的答案。
1. 为什么是 Ktor:本地优先 AI Agent 的选型逻辑
1.1 云上 Agent 的“三座大山”倒逼本地化
先说说我为什么非得做“本地优先”。我的使用场景大概是这样的:内网环境里跑一些数据分析任务、整理本地知识库、执行定时脚本、还有给深度嵌入式离线设备做交互式问答。这些场景有一个共同点——它们都不适合把数据传到云端。
第一是隐私问题。把企业内部文档、代码片段、甚至是未公开的调研数据发到第三方 API 做推理,即使有保密协议,心里也不踏实。
第二是延迟问题。云端大模型的接口虽然稳定,但每次请求经过网络往返、排队、流式返回,首字延迟动不动就是两三秒。对于我这种需要 Agent 频繁调用工具、来回多轮迭代的场景,总延迟会被放大到一个完全不可用的量级。
第三是成本问题。一套几十万词的本地文档,如果要做结构化抽取和摘要,按 token 计费的话,跑一次就是一笔不小的开销。而本地模型虽然单次响应质量略逊,但跑 10 次也只是电费。
所以我想做的是:Agent 本体、模型推理、历史会话、工具执行全部留在本地。云端只做一件可做可不做的事情——模型没有能力回答时,才通过用户主动授权走一次远程补充。这叫 local-first,不是 offline-only。也就是说,本地优先的重点不是“绝不联网”,而是“默认离线工作,网络只作为增强手段”。
1.2 技术栈对比:FastAPI、Node.js 与 Ktor 的真实差异
确定本地优先之后,接下来就是语言和框架的选型。当时摆在面前的主要有三条路。
Python + FastAPI 是最常见的,生态确实强,LangChain、LlamaIndex、各种模型 SDK 都是 Python 优先。但我最头疼的是 Python 的部署和进程管理。在嵌入式或无头服务器上,Python 环境依赖容易碎,virtualenv 一乱就非常痛苦;而且 Python 的异步在高并发流式场景下,GIL 的限制仍然会体现出来,进程间通信和内存管理也偏笨重。
Node.js + Express / Fastify 也试过。Node 的异步模型做长连接、流式输出很顺手,npm 生态里也有不少 AI 相关的库。但它的短板在于:Agent 这种需要强类型的领域模型、复杂状态管理、以及交叉调用各种工具协议的场景,Node 的运行时类型安全需要靠 TypeScript 严格约束才能勉强达到 Kotlin 原生就有的水平,编译期的静态检查还是弱一些。
Ktor 的优势则非常明显:
- 纯 Kotlin + 协程,写异步代码就像同步代码,代码审查起来不会有回调地狱;
- 内嵌 Netty / CIO 引擎,可以打出一个 fat jar,扔到任何有 JRE 的机器上就能跑;
- 类型安全:整个 HTTP 路由、请求体解析、响应流都用类型约束,重构时编译器先帮我挡住一半雷;
- 天然支持 WebSocket、SSE、HTTP/2,做一个 Agent 网关所需的协议几乎都覆盖了;
- JVM 生态的 JDBC / SQLite / RocksDB 等数据层很成熟,做 local-first 的读写很顺手。
我不否认,如果只追求“最快跑通 demo”,Python 依然是首选;但要追求“一个能长期运行的本地服务”,Ktor 的可靠性要高得多。
1.3 本地优先(Local-First)到底意味着什么
这里要稍微展开说一下“本地优先”的软件架构含义。它不是简单地把一个 HTTP 服务的地址从云端改成127.0.0.1,而是整个数据流的默认路径都在本机闭环。
- 数据获取默认来源是本地文件系统、本地数据库、本地知识库,而不是外部 API;
- 状态存储默认是本地文件、SQLite、或者内嵌 KV,而不是 Redis;
- 模型推理默认调用本地推理服务(比如 Ollama、llama.cpp),而不是云端模型接口;
- 网络请求只有两条路径会触发:用户明确授权联网搜资料,或者本地能力不足时的模型远程补充。
这个原则对 Agent 的行为设计影响很大。例如,如果我设计了一个“读取本周日志并汇总异常”的任务,Agent 会先扫描本地日志文件,而不是先去请求一个日志平台 API。这种设计不仅减少依赖,而且让整体系统的可复现性大大增强——换一台机器,拉下代码和模型权重,整个 Agent 就能跑起来,中间不被任何外部凭据卡住。
2. 项目骨架与核心架构:Ktor 在本地优先场景下的落地方式
2.1 Gradle 依赖与项目初始化
我用的是标准的 Kotlin Multiplatform 之外的 JVM-only 结构,因为目标平台就是桌面和服务器两种。Gradle 核心依赖长这样:
plugins { kotlin("jvm") version "2.0.21" kotlin("plugin.serialization") version "2.0.21" application } dependencies { implementation("io.ktor:ktor-server-core:3.0.0") implementation("io.ktor:ktor-server-netty:3.0.0") implementation("io.ktor:ktor-server-content-negotiation:3.0.0") implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.0") implementation("io.ktor:ktor-server-websockets:3.0.0") implementation("io.ktor:ktor-server-sse:3.0.0") implementation("io.ktor:ktor-client-core:3.0.0") implementation("io.ktor:ktor-client-cio:3.0.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.xerial:sqlite-jdbc:3.47.1.0") implementation("com.zaxxer:HikariCP:6.2.1") implementation("org.slf4j:slf4j-simple:2.0.16") }这里值得多说两句。Ktor 3 相比 2.x 最大的变化是路由 DSL 和插件机制更统一了,install阶段更清晰。我一开始用了 2.x,后来几个 WebSocket 和 SSE 的兼容性坑促使我直接升到 3.x。如果你是新项目,直接上 3.x 就好,不用走我的弯路。
数据库选型上,我没有上 MongoDB 或者 Postgres,因为本地优先的 Agent 并不需要分布式事务,反而更需要“开箱即用”和“单文件存储”。SQLite + HikariCP 连接池是很成熟的组合,既能保证并发读写的稳定性,又能把整个状态库做成一个.db文件,备份或转移都只是复制一个文件的问题。
2.2 以 Netty 为引擎的嵌入式服务器
Ktor 的好处之一是可以把服务端和客户端都嵌进同一个进程。我的 Agent 进程启动时,会同时做几件事:
- 启动内嵌的 Ktor 服务器,监听
127.0.0.1:8086,这个端口负责提供给前端 UI、命令行工具或者其他进程调用 Agent 的 HTTP / WebSocket 接口; - 初始化 SQLite 连接池和会话存储;
- 启动一个到本地模型推理服务的客户端连接(Ollama 默认
11434端口); - 加载工具注册表,把内置工具(文件操作、数据库查询、代码执行、HTTP fetch 等)注册到 Agent 运行时的调度器。
核心代码大致是这样:
fun main() { val config = AppConfig.load() val agentRuntime = AgentRuntime(config) embeddedServer(Netty, port = config.port, host = "127.0.0.1") { install(ContentNegotiation) { json() } install(WebSockets) configureRoutes(agentRuntime) }.start(wait = true) }用 Netty 引擎而不是默认的 CIO,是因为 Netty 在吞吐量和长连接管理上更成熟。尤其是 WebSocket 支撑的流式日志推送、Agent 状态订阅,Netty 的线程模型比 CIO 更稳。实测下来在高并发场景 Netty 的线程调度开销确实更低。
2.3 本地数据层设计:SQLite 与会话持久化
Agent 的状态可以粗暴地分成两类:长期知识和短期会话。长期知识包括工具调用的记录、各类文档的元数据、用户偏好、自定义指令;短期会话则是一个个对话线程的上下文。
我的表结构非常简单实用:
sessions:会话 ID、标题、创建时间、更新时间、当前使用的模型名;messages:会话 ID、消息 ID、角色(user / assistant / tool)、内容 JSON、token 数、时间戳;tool_calls:工具调用 ID、消息 ID、工具名、入参 JSON、结果 JSON、耗时、状态;kv_store:通用键值存储,放 Agent 的系统配置、用户偏好等。
CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, content_json TEXT NOT NULL, token_count INTEGER DEFAULT 0, created_at INTEGER NOT NULL ); CREATE INDEX idx_messages_session ON messages(session_id, created_at);为什么把 content 存成 JSON 而不是纯文本?因为 Agent 消息里除了文本,还有工具调用的结构化参数、图片引用的路径、以及 token 级别的元数据。JSON 列能让我在不改表结构的情况下自由扩展消息形态。
2.4 与本地模型服务交互的抽象层
这个抽象层是整个项目里改动最频繁、也最容易被低估的部分。我把它设计成一个名为ModelGateway的接口:
interface ModelGateway { fun streamChat(request: ChatRequest): Flow<ChatChunk> suspend fun completeEmbedding(text: String): List<Float> suspend fun healthCheck(): ModelHealth }这个接口的意义在于:它允许我在不修改业务层的前提下,切换不同的模型后端。真实项目中我实现了两个实现类,一个是 Ollama 的 REST 客户端,一个是 OpenAI 兼容模式的本地代理客户端(有些内网环境里会跑 vLLM 或者 llama.cpp 的 server 模式)。
为什么要做这一步抽象?因为很多 Agent 项目死在“换模型就像重写一遍系统”上。如果模型调用散落在代码各处,本地模型换成云端模型、Alpaca 换成 Qwen,就相当于做一次大型重构。而抽象一层之后,新模型接入的成本被压缩到了“实现一个新的 Gateway 类”的规模。
3. 核心链路实现:从收到请求到模型推理再到工具调用
3.1 流式响应的管道设计
Agent 交互和普通 REST API 最大的区别在于:用户的提问往往需要多轮推理,而每一轮都会产生流式中间结果。比如我问 Agent“分析一下这周日志里的异常并给出修复建议”,Agent 会:调用工具读取日志 → 把日志摘要拼接给模型 → 模型生成初步判断 → 调用另一个工具查询服务状态 → 生成最终报告。
这整个流程里,用户看到的应该是打字机式更新的过程,而不需要等五分钟拿到一个完整 JSON。所以我用 Ktor 的 SSE 做了流式推送。
fun Route.streamRoutes(runtime: AgentRuntime) { route("/agent") { get("/sessions/{id}/stream") { val sessionId = call.parameters["id"]!! val flow = runtime.attachSession(sessionId) call.respondSse { flow.collect { event -> send(event) } } } } }这里关键点是:attachSession返回的是一个Flow<ServerSentEvent>,而不是一个回调函数。Kotlin 的 Flow 天然适合做流式管道,因为它支持背压、取消、组合。我把 Agent 运行时每次产生的状态变化(工具调用开始、工具调用结束、模型吐字、错误)全部映射成 SSE 事件推给前端。前端只需要监听这个事件流,就能完整呈现 Agent 的“思考过程”。
3.2 工具注册与函数调用的状态机
Agent 最大的威力在于工具调用。我的工具注册方式非常轻量,用注解 + 反射扫描,而不是手写一大段配置。
@AgentTool( name = "query_database", description = "执行面向只读的 SQL 查询,返回结果集", parameters = [ Param("sql", "string", "SQL 查询语句"), Param("limit", "integer", "返回最大行数,默认 20") ] ) suspend fun queryDatabase(sql: String, limit: Int): String { return dbManager.query(sql, limit) }然后运行时通过反射读取注解注册到工具表。工具调用流程是一个有限状态机:
- 模型输出
tool_call,包含工具名和参数 JSON; - Agent 运行时校验工具名是否存在,参数是否合法;
- 执行工具:可能有本地 IO、数据库查询、HTTP 请求等副作用的操作;
- 把工具返回结果拼成一条
toolrole 的消息,再送回到模型上下文; - 模型继续推理,直到生成最终的
assistant消息或者下一次工具调用。
这个状态机的实现核心是一个suspend fun runTurn(),它内部用循环处理多次工具调用:
suspend fun runTurn(sessionId: String): String { var loops = 0 while (loops < MAX_LOOPS) { val response = modelGateway.streamChat(buildRequest(sessionId)).toCompletableMessage() persistMessage(sessionId, response) if (response.toolCalls.isEmpty()) { return response.content } for (toolCall in response.toolCalls) { val result = executeToolOrThrow(toolCall) persistToolResult(sessionId, toolCall.id, result) } loops++ } throw IllegalStateException("工具调用循环超过 $MAX_LOOPS 次") }这个状态机是 Agent 的“心脏”。我在实际使用中遇到的最常见问题,不是状态机本身跑不动,而是模型一旦产生“幻觉性的工具调用”(比如调用一个不存在的参数名称),整个循环就会卡住或者把错误信息反复发回给模型造成死循环。所以我在循环里加了两个保险:一是工具名严格校验,二是循环次数上限。
3.3 会话历史的本地存储与截断策略
大模型的上下文窗口是有限的,而 Agent 每轮工具调用都会产生大量 token。如果无脑把全量历史都塞给模型,几千字的问题直接超窗。
我的策略非常简单但有效:三级截断。
- 第一级:把工具调用结果中的大段原始数据做摘要,只保留结构化的核心结论。例如数据库查询返回 100 行,我只会让模型看到前 10 行 + 一个统计摘要;
- 第二级:按时间衰减,1 小时前的对话优先裁剪;
- 第三级:如果仍然超窗,保留系统提示词 + 最近三轮消息,丢掉中间部分。
这个策略是基于实践调出来的。最开始我把历史全部截断到最近 5 条,结果 Agent 完全没有长期记忆;后来改成按 token 预算截断,例如总预算 8000 token,系统提示占 2000,工具结果占 3000,历史对话占 3000,效果就好多了。
4. AI Agent 怎么扛并发:Ktor + 协程的实践笔记
4.1 协程模型下 IO 并发的真相
很多人在“Agent 怎么扛并发”这个问题上有一个误解:以为 Agent 并发就是简单地开多个线程调模型 API。实际上对于本地推理的 Agent,并发瓶颈根本不在 Agent 服务本身,而在下游的模型推理服务和本地 IO。
以 Ollama 为例,默认配置下同一时刻只能跑一个模型的推理请求(OLLAMA_NUM_PARALLEL默认是 1)。如果你给 Agent 服务开了 50 个并发请求,最后这 50 个请求会全部排队在 Ollama 这一个桶里,响应时间被无限拉长。
所以我在 Ktor 侧做的第一件事,就是对模型调用加了一个全局信号量:
val modelSemaphore = Semaphore(permits = config.modelParallelism) // 默认 1 suspend fun modelRequest(block: suspend () -> ChatChunk): ChatChunk { modelSemaphore.withPermit { return block() } }这样可以防止“Agent 服务本身并发很高,但模型层被打爆”的连锁问题。Kotlin 协程可以在不阻塞线程的情况下优雅等待信号量,这让 Agent 服务可以继续处理其他请求的 IO 和工具调用。
4.2 并发任务的排队、限流与取消
协程的优点不仅体现在并发执行上,更体现在“取消”上。Agent 跑一轮任务经常要几十秒,如果用户中途点击取消,或者 WebSocket 断开,服务端必须能立刻取消整条链路,而不是让这个任务继续占用模型资源。
Ktor 的 WebSocket 处理天然支持协程取消。我在实现“取消正在执行的 Agent 任务”时,基于CoroutineScope做了任务注册:
class TaskRegistry { private val tasks = ConcurrentHashMap<String, Job>() fun register(taskId: String, job: Job) { tasks[taskId] = job } suspend fun cancel(taskId: String): Boolean { return tasks.remove(taskId)?.cancelAndJoin() != null } }当 WebSocket 断开时,我会自动取消该连接对应的所有任务。这个功能看似简单,但在实际使用中价值极大:以前用 Python 写时,一旦用户中断连接,后台线程还继续跑,模型资源白白占用几十秒。协程配合结构化并发的取消机制,能精确地把资源归还给系统。
4.3 限流策略:拒绝“并发雪崩”
除了模型层信号量,我还给整个 Agent API 加了两个限流维度。
- 维度一:全局 QPS 限流。基于 Ktor 的拦截器实现,同一 IP 每秒最多 10 个请求,超过直接返回 429;
- 维度二:单会话并发限制。同一个会话 ID 同时只能有一个正在执行的任务。如果有新请求进来,先挂起等旧任务完成,或者直接返回“已有任务执行中”的错误。
这个设计非常贴合 AI Agent 的实际使用方式。用户很少会同时给同一个会话发多个任务,但用户可能开了多个会话窗口,每个会话各自执行自己的 Agent 流程。所以我的并发模型是“全局控制模型并发 + 会话内串行 + 会话间并行”。
实测下来,在一个 8 核 16G 的 MacBook Pro 上跑 Qwen2.5-7B,Ollama 单并发的前提下,Agent 服务的 20 个并发会话完全不会互相阻塞到不可用的程度:排队等待模型是预期内的事,但用户界面上的流式状态一直有反馈,体验还是可接受的。
4.4 可观测性:并发问题必须靠数据说话
最后提一下并发排障的工具。Ktor 提供了Diagnostics插件,可以打印每个请求的元信息,但还不够。我自己加了一个轻量级的指标端点,记录以下数据:
active_sessions pending_model_requests completed_tool_calls avg_tool_call_latency_ms average_time_to_first_token_ms这个端点输出的 JSON 不仅我开发时看,也让前端做一个/debug页面。每当用户反馈“卡住了”,我不再需要靠猜,直接看指标就能定位到底是模型排队、工具调用慢,还是数据库锁。
5. 踩坑记录:本地优先 AI Agent 的真实教训
5.1 流式 HTTP 响应的背压问题
做流式输出的过程中,我踩过一个典型的背压坑。最开始模型输出的流直接转发给 SSE 连接,代码写得很痛快:
modelGateway.streamChat(request).collect { chunk -> sseSession.send(chunk) }看起来没问题,但一旦模型生成速度快、而前端的消费速度慢(比如浏览器标签页切到后台,UI 线程被节流),send会阻塞。由于collect是串行的,阻塞就反过来拖慢模型输出的读取,形成链路阻塞。
后来我改成了带缓冲区的通道模型:模型流写入一个Channel(BUFFERED),SSE 发送端从通道中读取:
val channel = Channel<ChatChunk>(capacity = 64) launch { modelGateway.streamChat(request).collect { channel.send(it) } channel.close() } for (chunk in channel) { sseSession.send(chunk) }缓冲区满了之后,send会挂起而不是丢弃,这正好实现了自然的背压。这个改动让我彻底摆脱了“流式输出导致内存暴涨”的隐患。
5.2 WebSocket 断线重连与心跳设计
Agent 的长任务执行时,前端通过 WebSocket 订阅状态流。一开始我的 WebSocket 连接没有任何心跳,结果就是:用户电脑休眠几分钟,TCP 连接已经被系统断开,但服务端不知道,任务仍然在跑,重连之后用户看到的状态却是不连续的。
解决方式:服务端每 30 秒发一条心跳消息,客户端如果在 60 秒内没有收到任何消息,主动断开并重连;服务端监控心跳超时,超过 90 秒没有收到客户端的 pong,则关闭连接并取消这个连接下的所有任务。
Ktor 的 WebSocket 支持 Ping/Pong 帧,实现这个逻辑比较直接。但要注意的是,心跳消息不能和业务消息混在同一个状态机里,否则前端解析会比较混乱。我把它设计成了独立的EventType.PING/EventType.PONG,前端先判断类型再决定是否忽略。
5.3 JVM 桌面端资源路径与打包问题
如果你在本地跑 Agent,大概率会让你想要一个桌面界面。我用的是 Compose for Desktop,但这里最大的坑不在 UI,而在如何把 Ktor 服务和桌面 UI 打包进同一个应用分发。
第一次我用了shadowJar插件直接打 fat jar,一切正常;但是引入 SQLite 的 JDBC 驱动后,在 M1 Mac 上出现奇怪的Unable to load native library错误。本质原因是我把代理类放到了不同目录层级,导致 JNI 资源加载失败。
解决方案有三种,按推荐顺序:
- 使用
compose.desktop应用插件原生的packageDmg/packageMsi打包方式,让 Gradle 处理 JNI 依赖; - 使用
jpackage把 JDK 和依赖一起打包成原生镜像; - 如果坚持用 shadowJar,则手动在
shadowJar配置中把 JNI 相关.dylib/.so文件加进resources目录的固定路径。
这个坑我花了一个周末才爬出来,主要原因是 JNI 加载的报错日志非常具有迷惑性,它可能只提示某个.dylib找不到,但实际上是你打了多层嵌套的 jar,导致 URLClassLoader 找不到资源。
5.4 并发写 SQLite 的“database is locked”
另一个让我印象深刻的坑是 SQLite 的并发写锁。本地优先架构下,多个会话同时写入消息历史,如果连接池默认配置不合适,频繁出现SQLITE_BUSY。
我的解决组合是:
jdbcUrl: "jdbc:sqlite:agent.db" maximumPoolSize: 4 connectionTimeout: 3000同时开启 SQLite 的 WAL 模式:
PRAGMA journal_mode=WAL; PRAGMA busy_timeout=3000;WAL 模式可以显著减少写锁冲突,读操作不会阻塞写操作。但要注意,WAL 模式会产生额外的.db-wal和.db-shm文件,如果你要备份数据库,不能只复制.db文件。这个细节我在文档里专门标红了。
6. 开源之后:从代码仓库到社区项目的运营心得
6.1 开源前的准备工作:License、CI、可复现 Demo
项目在 GitHub 上开源时,我做了一系列准备工作,这里也分享给打算开源的读者。
- License 选了 MIT。Agent 框架类项目我倾向于宽松许可,方便被商业项目吸收;
- CI 用 GitHub Actions,跑了三个平台的 JVM 测试:
ubuntu-latest、macos-latest、windows-latest。原因很现实:本地优先的应用逃不开跨平台路径问题,早发现早解决; - 写了一个一键启动脚本,用 Docker Compose 同时拉起 Ollama 和 Agent 服务,让用户 clone 后 5 分钟就能看到 Agent 跑起来的样子。这一步对开源项目的存活率影响极大,因为没有“可复现 demo”的项目,issue 区会被各种环境问题淹没;
- 把配置项集中在一个
config.yaml,默认值保证零配置可运行。
6.2 收到 issue 后如何判断优先级
开源几个月后,陆陆续续收到一些 issue。我总结了一套判断优先级的方法:
- 第一优先级是“环境启动失败”类 issue。这类问题虽然和核心逻辑无关,但会直接卡住所有潜在用户,必须最快响应;
- 第二优先级是“功能不满足预期”类,比如工具调用格式不对、SSE 事件顺序混乱。这类问题通常需要复现对话记录,我提供了
--dump-session参数,让用户在遇到问题时导出一份脱敏的会话 JSON,极大提高了排查效率; - 第三优先级是“性能不达标”类。这类问题往往不能立刻解决,因为涉及模型层配置和硬件资源。我会先让用户提供
/debug/指标,再对比我的基准测试数据。
我还把“环境问题 list”总结成了一个TROUBLESHOOTING.md,把常见的端口占用、模型权重路径、WebSocket 代理不兼容等问题直接写进去。这之后,重复提问率明显下降。
6.3 后续路线图:三个方向的扩展思考
现在项目的核心路径已经稳定,我后续计划主要围绕三个方向。
方向一是补充更多内置工具的模板,比如定时任务、日历提醒、本地 Git 仓库分析。本地优先的 Agent 关键价值就是“它是你的贴身基础设施”,工具越贴近日常,越容易被依赖;
方向二是完善多模型的自动路由。当前是在配置里手动指定模型,下一步希望实现根据任务类型自动选择大模型或小模型。比如简单分类任务用 1.5B 小模型,复杂推理才用 7B 甚至更大的模型。这样既快又省电,对嵌入式设备尤其重要;
方向三是加一层“网络增强”的可控开关。本地模型能力不够时,自定义规则决定是否自动访问外部信息源,但是所有出站请求都必须被白名单约束,并且全程立即可见。毕竟 local-first 的核心是用户掌控一切,不是偷偷把数据送出去。
最后再分享一个小技巧:如果你用 Ktor 写类似的本地服务,一定尽早把/health、/debug/metrics这种运维端点和业务 API 分开。它可能看起来不“酷”,但在社区协作里,可观测性是让项目活下去的关键因素。AI Agent 跑起来之后,你真正需要的不再是灵光一现的 prompt 技巧,而是稳定、可控、能排查的系统基础工程。这套底子打扎实了,上层加再多的 Agent 能力都是水到渠成的事。