这个项目是从一个很具体的问题开始的:本地已经跑了一堆工具,有脚本、有数据库、有定时任务,但它们之间互不通信,每次想做点跨工具的操作都得手动连好几趟。后来接触到 AI Agent 的概念,第一反应是这正好能解决我的问题。但市面上的 Agent 框架要么默认绑云端大模型,要么跑起来就是一套重得要命的微服务,我只需要一个能常驻本地、通过自然语言调度已有工具的小东西。于是我决定自己写一个,技术栈选了 Kotlin 和 Ktor,项目开源,主打本地优先。这篇文章就把我做这个项目过程中的思路、架构和踩过的坑完整写出来。
1. 项目背景与需求拆解
1.1 为什么坚定选择“本地优先”
做这个项目之前,我其实先试过两三个现成的 Agent 框架,体验都很微妙。功能确实强大,但依赖链特别长,启动时要拉起一堆服务,配置里写满了各种云端模型的 API Key。在本地环境里,我就想让它帮我处理文件、查数据库、跑几个维护脚本,它却动不动把数据送到云端去理解,这在隐私和响应速度上都很难接受。
本地优先的意思不是拒绝所有云端能力,而是默认一切都在本机闭环运行:模型推理走本地推理引擎,工具调用走本机 shell、文件系统和数据库,Agent 的核心状态也只存在本地。好处非常直接。一是隐私边界清晰,数据不出机器,不需要为“哪些能传云端、哪些不能”反复纠结;二是延迟可控,本地模型的首 token 延迟虽然比不上大厂 API,但省去了网络抖动和排队,整体体验反而稳定;三是离线可跑,断网环境下开发调试完全不受影响。对个人项目和内网工具来说,这三条几乎就是刚需。
当然,本地优先也有代价。最大的代价就是模型能力上限被本地硬件锁死了。我的机器是普通开发本,只能跑 7B、13B 级别的量化模型,复杂逻辑推理和长文本理解确实不如云端大模型。所以我在设计 Agent 时做了一个妥协:把 Agent 做成模型无关的运行时,本地模型负责大部分常规任务的意图理解和工具选择,如果将来想在某个环节接入更大规模的云端模型,也能通过同一套接口切换,只是默认不开启。这样既保住了本地的默认路径,又留了升级的口子。
1.2 技术选型:为什么是 Ktor
选 Ktor 的原因可以拆成三层。第一层是我本来就在 Kotlin 生态里写后端服务,Ktor 的协程模型和 Kotlin 语言配合得非常顺,几乎感觉不到异步切换的割裂感。第二层是 Ktor 本身足够轻,它不是一个全家桶框架,核心只是一个 HTTP 引擎,路由、序列化、WebSocket 这些能力都以插件方式按需加载。对 Agent 这种需要快速启动、常驻命令行或桌面环境的程序来说,这一点很关键——我不希望为了一个 Agent 背上 Spring Boot 那么重的运行时。
第三层是被它的 Server 和 Client 一体性吸引。Ktor 同时提供服务端和客户端,两边共用同一套协程和序列化 API。Agent 对外要提供一个 HTTP 接口供交互,对内又要作为 HTTP 客户端去调用本地模型服务,比如 Ollama 的 API。用同一个框架写这两段代码,类型转换、错误处理、超时配置都是同一套心智,省掉了很多跨框架对接的麻烦。
也考虑过直接用 Node.js 或者 Python 写,但最终放弃的原因很实际:我希望 Agent 核心是编译型产物,发布时能打成一个可执行 jar 或者 native 包,不要求用户装 Python 环境,也不要求对方维护一堆 node_modules。Kotlin/Native 目前对我这种需要用到 JVM 生态库的场景还不够顺,所以现阶段以 JVM 为主,Ktor 正好能把服务端、客户端、序列化、协程这些粘合在一起。
1.3 Agent 到底要解决什么问题
我最初对 Agent 的定义非常朴素:给自然语言一个入口,让它能替代我完成一串固定的、跨工具的操作。比如我说“把昨天日志里所有 ERROR 行统计一下,然后按数量排序写进 report.md”,过去我要么写一段临时脚本,要么手动 grep、sort、重定向,现在我希望 Agent 自己拆解步骤,调用 shell、文件读写、文本处理,最后把结果写回文件。
但朴素需求拆开之后会发现,背后藏着几个绕不开的设计点。第一是意图解析,模型需要把用户的话转成可执行的工具调用序列,而不是直接输出一段泛泛而谈的文字。第二是工具抽象,每个工具必须有清晰的入参和出参,模型才知道什么时候该调哪一个。第三是状态管理,多轮对话里用户可能随时追加条件,“刚才说的那个文件”到底指哪个文件,需要 Agent 有记忆和引用能力。第四是错误恢复,工具执行失败是常态,Agent 不能直接崩溃,它应该把错误信息反馈给模型,让模型换一条路径重试。
这些点单独拿出来都不难,难的是它们要在一个长生命周期的进程里协同工作。所以我没有把项目做成一个玩具级的“聊天加工具”,而是从一开始就按一个可扩展的运行时来设计:模型是可替换的,工具是可插拔的,存储是可迁移的。Ktor 在这里不是出风头的角色,它负责把运行时提供一个稳定的 HTTP 外壳,但整个内核架构与框架解耦,这也是我能持续迭代的主要原因。
2. 整体架构与数据流设计
2.1 模块怎么划分
整个项目按职责分成四层。最外层是接入层,负责承接用户输入和输出交互,形态可以是一命令行程序、一个 REST 接口、一个 WebSocket 连接,甚至未来可以做桌面窗口,这一层只做协议转换,不碰任何 Agent 逻辑。第二层是 Agent 引擎层,包含会话管理、上下文组装、模型调用循环、工具决策这几个核心模块,它们共同决定了一次用户请求如何被理解、拆解和执行。第三层是工具层,每个工具就是一个独立的执行单元,接收 JSON 参数,返回 JSON 结果,内置在本地环境执行具体操作。最底层是基础设施层,包括存储、配置、日志和模型客户端,所有上层模块都只依赖这层提供的接口,不直接写死具体实现。
分层的好处不用多说,最直接的收益是测试容易。我可以把工具层单独拿出来,不启动模型就验证所有工具逻辑是否正确;也可以把 Agent 引擎层单独跑,用一个模拟模型返回固定的工具调用,验证状态流是否正常。对个人开源项目来说,这种可测试性太重要了,因为没人帮你做集成测试,你只能靠分层把出错范围尽量缩小。
数据流走的是经典的单轮循环加异步落盘。用户在接入层说了一句话,请求进入 Agent 引擎,引擎把历史上下文和当前消息拼成模型提示词,发给本地模型,模型返回一个结构化结果,可能是普通文本回复,也可能是工具调用指令。引擎解析结果,如果是工具调用,就把参数交给对应工具执行,拿到工具结果后再拼接成新一轮上下文继续发给模型,直到模型给出最终文本回复。整个过程是阻塞式循环,但对单用户本地场景完全够用,也最容易 debug。
2.2 工具调用的核心机制
工具是 Agent 和真实世界之间的桥梁,所以我花了很多心思设计它的描述方式。每个工具在注册时都提供一个 JSON Schema 风格的描述,包括名称、一句话说明、参数对象定义,以及是否会产生副作用。模型根据这份描述决定要不要调、调哪个、传什么参数。工具描述写得好不好,直接影响模型调用的准确率,这一点怎么强调都不过分。
实际工程里我给工具做了五个字段:name、description、parameters、sideEffects、handler。name 必须和代码里的函数名语义一致,description 要写清楚这个工具能做什么、不能做什么,parameters 是严格的 JSON Schema,sideEffects 标记是否只读操作,handler 是真正执行逻辑的 Kotlin lambda。注册工具就像这样:
fun fileTools(): List<ToolSpec> = listOf( ToolSpec( name = "read_file", description = "读取指定文本文件的内容,path 为绝对路径或相对当前工作目录的路径", parameters = mapOf( "type" to "object", "properties" to mapOf( "path" to mapOf("type" to "string", "description" to "文件路径") ), "required" to listOf("path") ), sideEffects = false, handler = { params -> readFileContent(params["path"] as String) } ), ToolSpec( name = "write_file", description = "把 content 写入指定文件,若文件存在则覆盖,注意该操作不可逆", parameters = mapOf( "type" to "object", "properties" to mapOf( "path" to mapOf("type" to "string"), "content" to mapOf("type" to "string") ), "required" to listOf("path", "content") ), sideEffects = true, handler = { params -> writeFileContent(params["path"] as String, params["content"] as String) } ) )模型返回的调用指令用 JSON 表示,格式大致是{"tool": "read_file", "arguments": {"path": "/tmp/a.txt"}},Agent 引擎解析后直接查注册表,把 arguments 映射到 handler 的入参。我在设计上刻意要求工具执行结果必须序列化成字符串返回给模型,因为本地模型的上下文能力有限,一个工具如果产生了超大输出,最好由工具自己做好摘要,而不是把几十万字节直接塞回对话上下文。
这里有个容易被忽略的细节:工具描述里的 description 不能写得太口语化,也不能太含糊。我一开始写“读取文件”四个字,模型经常不知道该传绝对路径还是相对路径。改成完整句并加上示例后,误调率明显下降。模型本身不笨,但它没有读代码的能力,描述就是它对工具的全部认知。
2.3 上下文与记忆怎么管理
上下文管理直接决定了 Agent 对话的连续性和模型有效输入长度。本地模型的窗口普遍不大,我日常用的模型上下文在 8K 到 32K 之间,但 7B 量化模型真正能高质量理解的内容远低于窗口上限,塞满一半就会开始胡言乱语。所以我的策略是三层记忆结构。
会话级记忆负责记录多轮对话的原始消息列表,这是最完整的存档。摘要级记忆负责维护一个不断压缩的对话摘要,每当原始消息条数超过阈值,就调用模型对已有对话做一次总结,把总结作为下一轮上下文的前缀。工具结果记忆比较特殊,它只保留当前轮次工具执行后的关键输出摘要,上一轮的工具结果不会永久留在上下文里,避免上下文被历史工具输出撑爆。
组合起来的效果是:每轮实际发给模型的上下文等于系统提示词、会话摘要、最近几轮消息、当前工具输出摘要。系统提示词里我会明确告诉模型“你是一个运行在本地环境的 AI Agent,你的回复必须简洁,优先调用工具完成任务”。摘要压缩和最近消息截断都做了,但我保留了“完整历史落盘到本地文件”这条兜底路径,任何一轮对话都能回溯,不用怕摘要丢细节。
实现摘要压缩的代码并不复杂,核心逻辑就是定期把历史消息丢给模型,让它生成一段不超过 N 个 token 的对话摘要。这里有个隐患:压缩本身也是一次模型调用,如果摘要 prompt 写得不好,模型可能会漏掉关键信息。我现在的方法是先把历史消息里的工具调用记录高亮出来,比如标注“用户要求排序文件时使用了 sort_files 工具”,让模型在摘要时优先保留这些动作性信息。效果比直接甩原始消息好很多。
2.4 本地模型接入层
模型接入层是 Agent 的一个适配器,我没有把某个具体模型厂商的 SDK 绑死在核心代码里。对外只定义一个接口,叫ModelProvider,它要做的事情听起来很简单:给你一串消息,返回模型输出结果。但这个简单接口背后要处理的事情很多,包括流式输出支持、超时重试、错误码分类、上下文截断。
本地推理我主要对接 Ollama。为什么选它而不是直接用 llama.cpp 的绑定,原因很现实:Ollama 已经处理好了模型格式转换、GPU 加速、常驻后台进程这些琐碎事,我只需要通过 HTTP 接口跟它通信,而且它的 OpenAI 兼容接口让我随时可以把 provider 切到远端模型。当然这不是唯一选项,我也保留了对 llama.cpp server 的适配潜力,但首版先把最省力的路径打通。
接入层还要负责处理模型返回的格式。我要求模型输出必须是严格 JSON,但本地小模型的 JSON 稳定性没那么好,经常多一个尾逗号或者少一个引号。所以我在接入层写了一个宽容解析器,先尝试标准解析,失败后再做一轮轻量修复,比如去掉注释、补全引号、提取最外层 JSON 块。这个解析器救了我很多次,没有它,Agent 的工具调用流程根本跑不起来。
Ktor 的 HttpClient 在这里派上了用场。本地模型地址是http://127.0.0.1:11434,客户端配置了连接超时 5 秒、请求超时 120 秒,这样模型卡死时 Agent 不会无限等下去,而是能报错并把错误信息纳入下一轮上下文,让模型自己决定是重试还是告知用户。
3. 实操过程与关键实现
3.1 从零搭建 Ktor 项目骨架
项目用 Gradle 构建,Kotlin 版本用 2.0 以上,Ktor 版本用 2.3 系,都是当前稳定线。依赖不算多,核心是ktor-server-core、ktor-server-netty、ktor-server-content-negotiation、ktor-client-core、ktor-client-okhttp,加上kotlinx-serialization-json,JSON 解析直接用官方序列化库。我特意没加数据库框架,先用一个简单的 JSON 文件持久化会话,后面再上 SQLite。
启动入口就是一个标准 Ktor embeddedServer,先起 HTTP 服务,再初始化 Agent 引擎和工具注册表:
fun main() { val toolRegistry = ToolRegistry() toolRegistry.register(fileTools()) toolRegistry.register(shellTools()) toolRegistry.register(dbTools()) val modelProvider = OllamaModelProvider( baseUrl = "http://127.0.0.1:11434", model = "qwen2.5:7b-instruct-q4_K_M" ) val agentEngine = AgentEngine( toolRegistry = toolRegistry, modelProvider = modelProvider, storage = JsonFileSessionStorage(Path.of("sessions")) ) embeddedServer(Netty, port = 8765) { install(ContentNegotiation) { json() } routing { route("/api/agent") { post("/chat") { val request = call.receive<ChatRequest>() val reply = agentEngine.processMessage(request.sessionId, request.message) call.respond(reply) } get("/tools") { call.respond(toolRegistry.describeAllTools()) } } } }.start(wait = true) }这段代码基本就是项目的全部骨架了。routing里暴露了两个 API,一个给用户发消息,一个给前端或者调试工具查看所有已注册工具的描述。把toolRegistry单独拎出来还有个好处:我可以在启动时打印一份注册表,快速核对每个工具的 JSON Schema 有没有写错。
3.2 Agent 主循环怎么实现
Agent 引擎的processMessage方法是整个项目的核心,它实现了一个标准的 ReAct 循环:思考、调用工具、观察结果、再思考。我用一个最大迭代次数来控制循环,默认 8 轮,防止模型陷入工具调用的死循环。每一轮迭代都向模型发送当前累积的上下文,模型如果返回tool_call,就执行工具并把结果附加到上下文继续;如果返回最终回复,直接跳出循环。
实现里最关键的一点是把上下文构造成一个消息列表,而不是简单的字符串拼接。系统消息、用户消息、工具结果消息分别用不同角色标识,这样模型能更准确地理解哪部分是历史对话、哪部分是工具输出。给模型的消息列表大概是这个形态:
data class LlmMessage( val role: String, val content: String )构造上下文时,我会依次放入系统消息、摘要压缩消息、最近 N 条历史消息、当前用户消息、以及最近一轮的工具结果消息。历史工具结果我不全放进去,只放当前轮次的最近一次工具输出。原因前面说过,小模型窗口不够,塞太多历史工具输出反而会干扰判断。
循环里最重要的一个安全阀是“工具结果必须被校验”。工具返回之后,引擎先检查返回类型是否可序列化、有没有超过长度限制,再把它转换成消息。如果工具本身抛了异常,我不会让异常直接冒泡到 HTTP 接口,而是捕获后生成一条错误消息,内容为“工具执行失败,错误是 xxx,请根据错误调整参数后重试或向用户说明”。这样模型有机会自我纠错,比如路径不存在,它可能换个目录再试一次。
3.3 工具注册与执行器设计
工具执行器我把它做成了同步调用,这样 Agent 循环里每一步的时序都清清楚楚,不用为了并发而引入额外的复杂度。但工具本身也分快慢两种,快工具比如读文件、查内存表,几百毫秒内返回;慢工具比如执行一个长时间备份脚本,可能要几分钟。对于慢工具,我没有用异步回调,而是直接在调用前给模型提示“该工具运行时间较长”,并让 Agent 设置请求级别的超时上限。
工具注册表内部就是一个ConcurrentHashMap<String, ToolSpec>,注册时做重名校验。每个 ToolSpec 的 handler 接受Map<String, Any?>作为参数,内部再手动转换类型。这里我没有用反射自动绑定,因为 JSON Schema 的类型和 Kotlin 类型之间的映射没那么简单,手动转换虽然啰嗦,但可读性和可控性都更好。
一个典型的手写工具实现长这样:
ToolSpec( name = "list_processes", description = "列出当前系统中占用 CPU 最高的 10 个进程,适合排查资源占用问题", parameters = mapOf( "type" to "object", "properties" to emptyMap<String, Any>(), "required" to emptyList<String>() ), sideEffects = false, handler = { _ -> val result = ProcessBuilder("ps", "-eo", "pid,pcpu,pmem,comm", "--sort=-pcpu") .redirectErrorStream(true) .start() .inputStream.bufferedReader().readText().lineSequence().take(11).joinToString("\n") mapOf("processes" to result) } )工具返回统一用Map<String, Any?>,最后序列化成 JSON 字符串给模型看。设计上我没有让工具直接返回 Kotlin 对象,因为 JSON 化之后才能保证模型看到的文本是稳定格式,不然不同工具返回不同结构,模型会无所适从。
3.4 本地存储与配置管理
存储这一块我虽然前面说先用 JSON 文件,但实际项目里我还是加了 SQLite,因为会话记录一多,全量读写 JSON 文件就变得很笨重。SQLite 通过 JDBC 驱动接入,Ktor 不关心存储层,Agent 引擎也不关心,只有存储实现自己知道数据放哪。这样后续想换成其他数据库,只需要重写一个 Storage 实现。
存储内容分两张表,一张存会话元数据,一张存消息记录。会话表字段包括 session_id、create_time、摘要文本、消息条数。消息表包括 id、session_id、role、content、timestamp。每次收到用户消息和每次模型回复,都会异步写入消息表。异步写入很关键,因为如果同步写,模型生成响应后还要等磁盘 IO 才能返回,体验会差不少。
配置管理没有用 Properties 文件那种传统方式,我直接读环境变量加一个可选的agent.conf文件。配置项包括模型地址、模型名称、最大迭代次数、上下文保留轮数、数据存储路径。默认值都写在代码里,用户不配也能跑。这个设计是我从很多开源项目里学来的:本地工具默认零配置启动,能用再谈个性化。
3.5 命令行客户端与调试方法
服务端起来以后,我用 Ktor Client 写了一个极简命令行客户端,方便直接在终端里和 Agent 对话。客户端逻辑很简单:循环读 stdin,把输入 POST 到/api/agent/chat,打印返回结果。它的作用不只是测试,也是我平时真正使用这个 Agent 的主要入口。
fun main() = runBlocking { val client = HttpClient(OkHttp) { install(ContentNegotiation) { json() } } val scanner = Scanner(System.`in`) var sessionId = UUID.randomUUID().toString() while (true) { print("> ") val line = scanner.nextLine() if (line == "/quit") break if (line == "/newsession") { sessionId = UUID.randomUUID().toString(); continue } val response = client.post("http://127.0.0.1:8765/api/agent/chat") { contentType(ContentType.Application.Json) setBody(ChatRequest(sessionId, line)) }.body<ChatReply>() println("Agent: ${response.reply}") } }调试工具调用时,我还加了一个/api/agent/debug接口,返回最近一轮的完整上下文消息列表,包括每轮工具调用和结果。这个接口是排查问题的神器。模型如果连续几次选错工具,看一眼上下文就知道是描述出了问题,还是摘要压缩丢了关键信息。
4. 常见问题与排查技巧
4.1 协程并发下的状态污染
Ktor 本身就是协程驱动的,但 Agent 引擎内部如果用可变状态,就必须小心并发。我最开始直接把历史消息列表存在内存里,每个 session 一个MutableList<LlmMessage>,在多用户并发请求下,两条消息可能会互相覆盖,甚至导致上下文顺序错乱。
解决方法是在 AgentEngine 里维护一个ConcurrentHashMap<String, AgentSession>,每个 session 对象内部所有可变字段都通过synchronized保护,或者干脆用不可变数据加copy更新。我选择了后者,因为 Kotlin 的 data class 做局部更新很方便,而且不容易写出隐蔽的竞态。实际效果是,单个 session 的请求是串行处理的,但不同 session 之间可以并行,吞吐够用且逻辑安全。
排查这类问题有一个笨办法但特别好用:在每次进程消息的前后打印 session 的消息条数和最后几条消息的 hash,如果出现高并发下 hash 对不上,基本就是有并发写入。
4.2 模型返回格式不稳定
本地小模型的 JSON 输出经常不稳定,这是所有接本地模型做 Agent 的人都会遇到的问题。除了前面提到的宽容解析器,我还用了两个土办法。一个是在系统提示词里给出“你只能输出如下格式的 JSON”的示例,并且示例写得非常具体;另一个是在解析失败后做一次“纠错重试”,把解析错误信息返回给模型,让它重新生成一次。
重试还能解决另一种情况:模型本来该调用工具,却直接回答了用户,没有走工具逻辑。这种时候我会额外追加一句提示:“如果用户请求需要操作本地文件或执行命令,你应当调用对应工具,而不是直接猜测结果。” 这一招对防止模型胡编数据非常有效。
最严重的一次问题是模型把工具参数里的路径字符串写成了不带引号的裸词,导致 JSON 直接破损。我最后在宽容解析器里加了一个规则:如果发现某个位置的值明显是字符串但这个值没有引号包裹,就自动尝试修复。这个规则有风险,但实际命中率很高,能救回大约三分之一原本会失败的解析。
4.3 上下文溢出和摘要丢失
上下文溢出是最常见又最影响体验的问题。我一开始把最近 20 轮历史全放进上下文,7B 模型直接表示看不懂了。后来调成“最近 6 轮 + 摘要”,效果立刻改善。但摘要本身也坑过我,有一版摘要 prompt 太笼统,模型把用户的原始诉求给摘要没了,Agent 开始回答一些和目标无关的内容。
我发现一个有价值的技巧:摘要时不要让模型讲故事,而是让它列要点。比如“用户要求:1. 分析日志 2. 统计错误 3. 写入报告。已执行:读取日志、调用统计工具”。这种结构化的摘要即使信息有删减,关键动作不会丢。现在我的摘要压缩指令里直接要求输出固定格式,模型照做的概率高很多。
如果你的模型窗口实在太小,连摘要都没地方放,我建议在接入层做硬截断:保留系统提示词和最近两轮消息,其余全部丢弃。这会让多轮对话的短期记忆变差,但至少每一轮都能正常返回结果。对本地工具型 Agent 来说,用户更常发单轮指令,短记忆的影响没那么大。
4.4 打包分发的几个坑
开源之后最常被问的就是“怎么跑起来”。我把 Gradle 配置成能打出 fat jar,这样用户只要装好 Ollama、下载模型、放一个config.properties,然后java -jar agent.jar就能运行。但打包过程中遇到两个实际问题。
第一个是资源文件路径。我用Path.of("sessions")来存会话,工作目录不同会导致路径不一样。对本地工具来说这可以接受,但如果作为系统服务启动,就必须在配置里显式指定绝对路径。现在我会在启动日志里打印当前工作目录,方便排查。
第二个是 JDK 版本兼容。Ktor 2.3 在高版本 JDK 上一切正常,但用户如果用的是老 JDK,可能会碰到 TLS 或序列化相关问题。我的建议是直接要求 JDK 17 以上,并且在 README 里写明用sdkman或包管理器安装的推荐版本,别指望用户自己去排查。
5. 经验复盘与后续方向
5.1 本地优先的收益和边界
跑了大半年之后,我对本地优先的收益有了更具体的感知。最明显的是调试效率:我可以随时改工具代码,重启 Agent,整个过程不到三秒,不需要等云端依赖同步,也不需要担心配额被刷爆。对个人工具来说,这种“随便折腾”的感觉非常重要。
边界也很清晰:本地模型不适合做需要大量常识或专业知识的事情。比如让它写一份行业分析报告,7B 模型产出的内容结构不错但细节经不起推敲。我现在只在 Agent 的功能设计里放它擅长的任务,比如本地文件整理、命令执行、状态查询、提醒管理,这些都依赖工具而不是模型自己的知识,模型只要做好意图理解和参数提取就行。把模型的职责限定在“调度”而不是“生成知识”,本地模型就够用了。
另一个边界是生态依赖。本地 Agent 再强,也没法替代那些内置了云端知识库的成熟产品。所以我在设计工具时更偏爱底层的通用能力,比如执行 shell 命令、读写文件、调用 curl 访问内网服务,而不是为某个特定平台写死功能。这样 Agent 的通用性不会被局限在单一场景里。
5.2 如果重来,我会在第一天就做三件事
第一,我会先写一套完整的工具测试。很多工具 Bug 其实是后来才暴露的,比如路径里有空格导致命令解析失败。如果从第一天就给每个工具配上固定的在/离线测试,后面加新工具会轻松非常多。
第二,我会从一开始就用结构化日志,而不是 println。等 Agent 循环复杂之后,普通打印根本分不清是模型返回、工具调用还是存储写入,日志时间线混乱会浪费大量排查时间。用kotlinx.datetime加上每个事件的 sessionId、环节类型、耗时,问题定位效率能提升数倍。
第三,我会把上下文摘要策略做得更早更成熟。第一版只做了简单截断,导致一旦多轮对话稍长,模型就丢失早期信息,用户只能反复提醒。后来补上摘要压缩后,体验才稳定下来。这个功能应该是一开始就放进架构里的,而不是等出了问题再补。
最后分享一个很个人化的习惯:开源项目如果定位是“自己用的工具”,就不要过度设计。我一度想加上插件热加载、分布式部署、可视化编排这些花活,但后来全部砍掉了。对于本地优先的 Agent,稳定、可调试、快速启动才是核心。这个项目的演进方向也不会是变得更大,而是变得更顺手——让模型的调度更准、让工具更丰富、让使用成本更低。保持这个方向慢慢迭代,比我一开始设想的大而全路线走得更远。