Cat Blog
HomeBlogTools
Search
Language
Choose site style
Choose accent color
Click Effect
Theme

Cat Blog · Updated regularly. Source code is available on GitHub.

Index 01: Agent Loop — 让智能体开口说话

July 24th, 2026
AI后端

Series

使用Kotlin从0开发一个ClaudeCode

Series

使用Kotlin从0开发一个ClaudeCode

Progress 1 / 21

使用Kotlin从0开发一个ClaudeCode

Previous in series

This is the first post in this series.

Next in series

Index 02: Tool Use — 让智能体动手做事

目标

如果你用过 Claude Code,你一定熟悉这个循环:在终端输入需求,智能体调用 LLM 思考,然后执行工具、返回结果。这个循环是一切的基础——没有它,后面所有的工具调用、权限审批、多智能体协作都无从谈起。

Index 01 的目标就是实现这个循环的最简版本。具体来说,我们要做三件事:

  1. 定义一个 LLM 抽象接口,让 AgentLoop 不依赖具体的 API 实现。今天接 Anthropic,明天换 OpenAI,只需要新增一个实现类。
  2. 实现 REPL 交互,用户可以在终端持续对话,支持 /help、/clear、/exit 等命令。
  3. 建立分层架构和测试策略,这 15 个文件将成为后续 19 个 Index 的地基。

完成后的效果如下——创建 .env 文件,一行启动:

BASH
$ echo 'CAT_CODE_API_KEY=sk-ant-xxx' > .env
$ ./gradlew run

🐱 Cat-Code — Index 01: Agent Loop
Type /help for commands, /exit to quit.

> 用 Kotlin 写一个冒泡排序
当然,这里是一个简洁的冒泡排序实现:

```kotlin
fun bubbleSort(arr: IntArray): IntArray {
    for (i in 0 until arr.size - 1)
        for (j in 0 until arr.size - i - 1)
            if (arr[j] > arr[j + 1])
                arr[j] = arr[j + 1].also { arr[j + 1] = arr[j] }
    return arr
}

/exit Goodbye! 🐾

TEXT

通过 CLI 参数或 `.env` 文件,你可以完整控制 LLM 的连接信息。这在实际使用中很重要——我们可能连接 Anthropic 官方 API,也可能通过代理访问 DeepSeek 的 Anthropic 兼容端点:

```bash
cat-code \
  --base-url https://api.deepseek.com/anthropic \
  --model deepseek-v4-flash \
  --max-tokens 8192

依赖选型

在写第一行代码之前,我们需要锁定技术栈。Kotlin 生态中每个领域都有多个选择,这里列出我们选的方案和理由:

KOTLIN
// build.gradle.kts
plugins {
    kotlin("jvm") version "2.3.21"
    kotlin("plugin.serialization") version "2.3.21"
    application
}
application { mainClass.set("com.sepcai.code.MainKt") }

dependencies {
    // clikt: Kotlin 最流行的 CLI 库,声明式参数解析,支持 envvar 回退
    implementation("com.github.ajalt.clikt:clikt:5.0.3")

    // ktor-client: Kotlin 原生的异步 HTTP 客户端,CIO 引擎纯协程无阻塞
    implementation("io.ktor:ktor-client-core:3.1.2")
    implementation("io.ktor:ktor-client-cio:3.1.2")

    // kotlinx-serialization: 编译期生成序列化代码,无反射,性能优于 Gson/Jackson
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")

    // kotlinx-coroutines: suspend 函数 + 结构化并发
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")

    // kotlin-logging: SLF4J 的 Kotlin 惯用封装,{} 惰性求值
    implementation("io.github.oshai:kotlin-logging-jvm:7.0.6")
    implementation("ch.qos.logback:logback-classic:1.5.18")

    // Kotest: Kotlin 原生测试框架,StringSpec 描述风格
    testImplementation("io.kotest:kotest-runner-junit5:5.9.1")
    // ktor-client-mock: MockEngine 拦截 HTTP 请求,无需真实网络
    testImplementation("io.ktor:ktor-client-mock:3.1.2")
}

这里有一个细节值得说明:我们引入了 ktor-client-content-negotiation 但实际上没有用它的自动反序列化。原因是 ktor 的 response.body<T>() 需要安装 ContentNegotiation 插件并注册 JSON serializer,而手动调用 response.bodyAsText() + json.decodeFromString() 只多了三行代码,依赖更少、调试更直观(可以直接 log 原始 JSON 文本)。


架构:五层单向流动

整个 Index 01 由 15 个源文件组成,按依赖方向分为五层。上层依赖下层,下层不知道上层的存在:

TXT
                  ┌──────────────────────┐
                  │  config/              │
                  │  EnvFile.load()       │  .env 解析 + 路径搜索
                  │  ConfigResolver       │  CLI > env > .env > 默认值
                  └──────────┬───────────┘
                             │ Map<String,String>
┌──────────┐    ┌────────────▼──────────┐    ┌─────────────────┐    ┌─────────────────────┐
│ Main.kt  │───▶│  ReplLoop              │───▶│  AgentLoop       │───▶│  LLMProvider        │
│ clikt    │    │  readLine() → 命令分发  │    │  history + run() │    │  suspend fun chat() │
│ 8选项    │    │  /help /exit /clear    │    │  sanitizeHistory │    │                     │
│ 110行    │    └───────────────────────┘    └─────────────────┘    └──────────┬──────────┘
└──────────┘                                                                   │
                                                                    ┌──────────▼──────────┐
                                                                    │ AnthropicProvider    │
                                                                    │ Anthropic + OpenAI   │
                                                                    │ 双格式 fallback 解析  │
                                                                    └─────────────────────┘
层关键类行数核心职责
配置EnvFile + ConfigResolver114 + 71.env 解析 + 四级优先级(CLI > env var > .env > 默认值)
入口CatCodeCommand110clikt 参数定义 + 依赖对象组装
终端ReplLoop123readLine 循环 → 命令分流 → println 输出
智能体AgentLoop111消息历史管理 → sanitize → LLM 调用
APIAnthropicProvider207HTTP POST → 双格式 JSON 解析 → stop_reason 映射

这种分层的实际好处是:每一层都可以独立测试。测试 AgentLoop 时,我们注入一个不联网的 FakeLLMProvider;测试 AnthropicProvider 时,我们用 MockEngine 拦截 HTTP 请求。没有任何测试需要真实的 API Key 或网络连接。


第一层:数据模型

任何项目都是从"数据长什么样"开始的。我们定义了三个基础类型,它们会被所有其他模块引用。

Message — 对话中的最小原子

KOTLIN
enum class Role { SYSTEM, USER, ASSISTANT }

data class Message(val role: Role, val content: String)

Role 枚举和 Message 放在同一个文件里。它们是一个概念的两面——Role 描述"谁说的",Message 描述"说了什么"。拆成两个文件会让每次使用都多一行 import,且阅读时需要在两个文件之间跳转。

ChatOptions — 每次 LLM 请求的可调参数

KOTLIN
data class ChatOptions(
    val model: String = DEFAULT_MODEL,
    val maxTokens: Int = DEFAULT_MAX_TOKENS,
    val temperature: Double = DEFAULT_TEMPERATURE
) {
    companion object {
        const val DEFAULT_MODEL = "claude-sonnet-4-6"
        const val DEFAULT_MAX_TOKENS = 4096
        const val DEFAULT_TEMPERATURE = 0.7
    }
}

所有默认值提取为 companion object 常量——这是整个项目"魔法值禁令"的第一个实例。Main.kt 中的 --model 选项默认值直接引用 AgentConfig.DEFAULT_MODEL,无需硬编码字符串。修改默认模型时只需要改一处。

StopReason — 决定循环的下一步

KOTLIN
enum class StopReason {
    END_TURN,       // 正常结束,输出文本给用户
    TOOL_USE,       // LLM 请求调用工具(Index 02 实现)
    MAX_TOKENS,     // 超过 token 上限,回复被截断
    STOP_SEQUENCE,  // 触发了自定义 stop_sequence
    ERROR           // API 调用失败
}

TOOL_USE 已经声明了,但 Index 01 的 AgentLoop 不会对它做任何特殊处理——它只会被当作普通文本返回给用户。这个枚举值是 Index 02 的"施工预留口":当工具系统就绪后,AgentLoop 会在这里插入 when (stopReason) { TOOL_USE -> dispatchTool(...) } 的分支。


第二层:LLM 抽象

这一层由一个接口和它的 Anthropic 实现组成。接口决定了上层代码如何与 LLM 对话,实现决定了我们如何与具体的 API 通信。

LLMProvider — 整个项目最重要的接口

KOTLIN
interface LLMProvider {
    suspend fun chat(messages: List<Message>, options: ChatOptions): ChatResponse
}

data class ChatResponse(val content: String, val stopReason: StopReason)

只有一个方法。没有 chatStreaming(),没有 countTokens(),没有 listModels()。这些是后续 Index 的事。Index 01 只需要一件事:发消息,收回复。接口的简单性是可测试性的前提——实现一个 FakeLLMProvider 只需要 10 行代码。

ChatResponse 不是 sealed class。Kotlin 的 sealed class 在 when 表达式中可以强制穷举,但在跨模块传递数据的场景中,data class 的简洁性更合适。Index 02 来临时,我们会在这里增加 toolCalls: List<ToolCall> 字段。

AnthropicProvider — 双格式解析

这是 Index 01 中最复杂的一个类(207 行)。它的核心挑战是:不同的 LLM API 返回不同格式的 JSON,但我们希望上层代码不需要关心这个差异。

Anthropic 官方 API 的响应格式是:

JSON
{
  "id": "msg_001", "type": "message", "role": "assistant",
  "content": [{"type": "text", "text": "Hello!"}],
  "stop_reason": "end_turn"
}

而 OpenAI 兼容 API(包括 DeepSeek 的 Anthropic 端点)返回的是:

JSON
{
  "id": "chatcmpl-001", "object": "chat.completion",
  "choices": [{"message": {"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}]
}

我们的处理策略是先尝试 Anthropic 格式,解析失败则 fallback 到 OpenAI 格式。关键代码:

KOTLIN
class AnthropicProvider(
    private val apiKey: String,
    private val client: HttpClient,
    private val baseUrl: String = DEFAULT_BASE_URL,
    private val messagesPath: String = DEFAULT_MESSAGES_PATH
) : LLMProvider {

    override suspend fun chat(messages: List<Message>, options: ChatOptions): ChatResponse {
        // Anthropic API 的 system prompt 放在请求体顶层字段,而非 messages 数组中
        val systemPrompt = messages.filter { it.role == Role.SYSTEM }
            .joinToString("\n\n") { it.content }.takeIf { it.isNotBlank() }

        val conversationMessages = messages.filter { it.role != Role.SYSTEM }
            .map { AnthropicMessage(role = it.role.name.lowercase(), content = it.content) }

        val responseText = client.post("$baseUrl$messagesPath") {
            contentType(ContentType.Application.Json)
            header("x-api-key", apiKey)
            header("anthropic-version", "2023-06-01")
            setBody(json.encodeToString(AnthropicRequest.serializer(), request))
        }.bodyAsText()

        logger.trace { "Raw API response: $responseText" }
        return parseResponse(responseText)
    }

    private fun parseResponse(responseText: String): ChatResponse {
        // 尝试 Anthropic 格式
        val anthropic = json.decodeFromString(AnthropicResponse.serializer(), responseText)
        if (anthropic.content.isNotEmpty()) {
            val text = anthropic.content.filter { it.type == "text" }.joinToString("") { it.text }
            return ChatResponse(text, mapStopReason(anthropic.stopReason))
        }

        // Fallback: OpenAI 兼容格式
        val openAI = json.decodeFromString(OpenAIChatResponse.serializer(), responseText)
        if (openAI.error != null) return ChatResponse("API Error: ${openAI.error!!.message}", StopReason.ERROR)

        val text = openAI.choices.firstOrNull()?.message?.content ?: ""
        return ChatResponse(text, mapOpenAIStopReason(openAI.choices.firstOrNull()?.finishReason ?: ""))
    }
}

这里有一个容易出错的地方:所有响应模型的字段必须有默认值。我们的 AnthropicResponse 中 id、type、role 都有 = "",content 有 = emptyList()。这是因为 kotlinx.serialization 在遇到缺失字段时默认会抛 MissingFieldException——即使我们设置了 ignoreUnknownKeys = true。有了默认值后,OpenAI 格式的 JSON(缺少 content 和 stop_reason 字段)也能被成功解析为 AnthropicResponse——只是 content 为 emptyList(),触发 fallback 逻辑。

两个独立的 mapStopReason 和 mapOpenAIStopReason 方法各自处理自己的领域词汇:Anthropic 的 end_turn 和 OpenAI 的 stop 都映射为 StopReason.END_TURN,Anthropic 的 max_tokens 和 OpenAI 的 length 都映射为 StopReason.MAX_TOKENS。未知值不抛异常——降级为 END_TURN 并打一条 warn 日志。


第三层:AgentLoop — 项目的心脏

AgentLoop 管理对话历史、调用 LLM、追加回复。111 行代码,是后续 19 个 Index 都要围绕展开的核心。

KOTLIN
class AgentLoop(
    private val llmProvider: LLMProvider,
    private val systemPrompt: String,
    private val config: AgentConfig = AgentConfig()
) {
    val messagesHistory: MutableList<Message> = mutableListOf()

    suspend fun run(userInput: String): String {
        sanitizeHistory()                                      // 修复异常状态
        messagesHistory.add(Message(Role.USER, userInput))     // 追加用户输入
        val response = llmProvider.chat(buildRequestMessages(), options())  // 调用 LLM
        if (response.stopReason != StopReason.ERROR)
            messagesHistory.add(Message(Role.ASSISTANT, response.content))
        return response.content
    }
}

这里有一个看似多余实则关键的操作:sanitizeHistory()。考虑这个场景:用户的网络超时,AnthropicProvider 返回了 StopReason.ERROR,run() 方法没有追加 ASSISTANT 消息。下一轮用户输入时,历史中就有两条连续的 USER 消息——Anthropic API 会直接返回 400 错误。

sanitizeHistory 在每次 run() 开头检查并清理这种异常状态:如果历史末尾有未应答的 USER 消息(上轮 ERROR 遗留),将其移除。用户无感知,AgentLoop 自动恢复了内部一致性。

另一个重要细节是 messagesHistory 是 public mutable。这不是封装不严——ReplLoop 的 /clear 命令需要 messagesHistory.clear(),Index 08 的 ContextCompact 也需要直接操作历史。写一个 clearHistory() 方法只是增加了一层没有语义价值的间接调用。


第四层:配置系统

随着开发推进,CLI 选项从最初的 --api-key 和 --model 两个扩展到了八个。每个选项的解析逻辑都一样:CLI 参数 → 环境变量 → .env 文件 → 默认值。如果不加整理,Main.kt 会变成 200 多行的 if-else 堆砌。

我们将配置分为两个独立类:

EnvFile 负责 .env 文件的加载和解析。它按优先级搜索三个位置:用户显式指定的路径(--env-file)→ 当前目录的 .env → 用户目录的 ~/.cat-code/.env。解析器是自己写的——支持 KEY=VALUE 格式、# 注释行、行尾注释和空白 trim,约 100 行代码,零外部依赖。

ConfigResolver 封装四级优先级逻辑。每个配置键一行调用:

KOTLIN
val r = ConfigResolver(EnvFile.load(envFilePath))

val apiKey   = r.string(cliApiKey,   "CAT_CODE_API_KEY",      default = "")
val baseUrl  = r.string(cliBaseUrl,  "CAT_CODE_BASE_URL",     default = DEFAULT_BASE_URL)
val model    = r.string(cliModel,    "CAT_CODE_MODEL",        default = DEFAULT_MODEL)
val maxToks  = r.int(   cliMaxTokens,"CAT_CODE_MAX_TOKENS",   default = 4096)
val temp     = r.double(cliTemp,     "CAT_CODE_TEMPERATURE",  default = 0.7)

ConfigResolver 本身也经过了一次设计迭代。最初我们在 CatCodeCommand 中写了三个私有方法(resolve、resolveInt、resolveDouble),每个方法都有五行的 if-else 链。抽取为独立类后,Main.kt 从 230 行缩减到 110 行,而且 ConfigResolver 可以独立测试——我们有 10 个测试用例覆盖了 String/Int/Double 的解析、.env fallback、cli 优先级和默认值回退。


第五层:REPL 交互

ReplLoop 是终端和智能体之间的桥梁。它的逻辑很简单——循环读取输入,以 / 开头的是内部命令,否则发给 AgentLoop:

KOTLIN
class ReplLoop(private val agentLoop: AgentLoop) {
    fun start() {
        while (true) {
            val input = readLine() ?: break  // Ctrl+D 退出
            when {
                input.startsWith("/") -> if (!handleCommand(input)) break
                else -> {
                    val response = runBlocking { agentLoop.run(input) }
                    println(response)
                }
            }
        }
    }
}

注意 runBlocking { agentLoop.run(input) } 这一行。AgentLoop.run() 是 suspend 函数(内部有 HTTP 调用),但 ReplLoop.start() 运行在主线程。runBlocking 桥接了这层同步和异步的边界。在 Index 02 中,我们会把它替换为更精细的协程作用域管理,让多个工具可以并发执行。

内部命令目前支持四个——所有命令名都是常量,避免拼写错误:

KOTLIN
private fun handleCommand(input: String): Boolean = when (input.split(" ")[0]) {
    "/exit", "/quit", "/q" -> false
    "/help", "/h"         -> { printHelp(); true }
    "/clear", "/c"        -> { agentLoop.messagesHistory.clear(); true }
    else                  -> { println("Unknown command. Type /help."); true }
}

测试:Fake 比 Mock 更好

Index 01 共有 37 个测试,分布在五个测试类中。我们没有使用 MockK 或 Mockito 这样的 mock 框架——所有假实现都是手写的。

对于 API 层的测试,我们用 ktor 的 MockEngine 拦截 HTTP 请求。它能精确控制返回的状态码、响应体和 Header,比真实网络调用快三个数量级:

KOTLIN
val mockEngine = MockEngine { request ->
    require(request.url.encodedPath == "/v1/messages")
    respond(
        content = ByteReadChannel("""{"content":[{"type":"text","text":"Hello!"}],"stop_reason":"end_turn"}"""),
        status = HttpStatusCode.OK,
        headers = headersOf(HttpHeaders.ContentType, "application/json")
    )
}
val provider = AnthropicProvider("test-key", HttpClient(mockEngine))
val response = provider.chat(listOf(Message(Role.USER, "Hi")), ChatOptions())
response.content shouldBe "Hello!"

对于逻辑层的测试,我们手写了一个 FakeLLMProvider:

KOTLIN
class FakeLLMProvider(var response: ChatResponse) : LLMProvider {
    var callCount = 0; private set
    lateinit var lastMessages: List<Message>; private set
    lateinit var lastOptions: ChatOptions; private set

    override suspend fun chat(messages: List<Message>, options: ChatOptions): ChatResponse {
        callCount++; lastMessages = messages; lastOptions = options; return response
    }
}

它比 Mock 框架好在哪里?当一个测试写成 val fake = FakeLLMProvider(ChatResponse("Hello", END_TURN)),读者一眼就知道"这个 LLM 会返回 Hello"。而 every { mock.chat(any(), any()) } returns ChatResponse(...) 不仅更冗长,还泄露了实现细节(调用次数、参数匹配器)。

层被测类技术用例数
数据MessageKotest5
配置EnvFile临时文件8
配置ConfigResolverKotest10
APIAnthropicProviderMockEngine7
逻辑AgentLoopFakeLLMProvider7

文件清单

TEXT
src/main/kotlin/com/sepcai/code/
├── Main.kt                         # clikt CLI 入口 (110行)
├── agent/
│   ├── AgentConfig.kt              # model / maxTokens / temperature / maxIterations
│   ├── AgentLoop.kt                # 消息循环核心 (111行)
│   └── StopReason.kt               # 停止原因枚举
├── config/
│   ├── ConfigResolver.kt           # 四级优先级解析器 (71行)
│   └── EnvFile.kt                  # .env 解析器 + 路径搜索 (114行)
├── llm/
│   ├── AnthropicModels.kt          # API JSON 模型 (Anthropic + OpenAI 双格式)
│   ├── AnthropicProvider.kt        # HTTP 适配器 + 双格式解析 (207行)
│   ├── ChatOptions.kt              # 对话参数
│   ├── LLMProvider.kt              # LLM 抽象接口 + ChatResponse
│   └── Message.kt                  # Message + Role
└── repl/
    └── ReplLoop.kt                 # REPL 交互循环 (123行)

src/test/kotlin/com/sepcai/code/
├── agent/   AgentLoopTest.kt       # 7 tests
├── config/  ConfigResolverTest.kt  # 10 tests
│            EnvFileTest.kt         # 8 tests
└── llm/     AnthropicProviderTest.kt # 7 tests
             MessageTest.kt         # 5 tests

.env.example                        # 配置模板

15 个源文件 · 37 个测试 · 全部通过。 整个项目除了 Gradle 和 JDK 之外,唯一的启动依赖是一个 LLM API Key。


下一站

Index 01 的 AgentLoop 目前只能"说",不能"做"。下一篇文章中,我们将实现 Tool Use——工具系统:

  • ToolRegistry 注册表,用 Map<String, Tool> 管理可用工具
  • 三个内置工具:ReadFileTool、WriteFileTool、BashTool
  • 并发工具调用,用 async { } 并行执行多个工具
  • AgentLoop 的 maxIterations 从 1 调整为 10,让它能在"调用 LLM → 执行工具 → 再次调用 LLM"的循环中真正运转起来

Table of Contents

Current section:目标

  • 1. 目标
  • 2. 依赖选型
  • 3. 架构:五层单向流动
  • 4. 第一层:数据模型
  • 5. Message — 对话中的最小原子
  • 6. ChatOptions — 每次 LLM 请求的可调参数
  • 7. StopReason — 决定循环的下一步
  • 8. 第二层:LLM 抽象
  • 9. LLMProvider — 整个项目最重要的接口
  • 10. AnthropicProvider — 双格式解析
  • 11. 第三层:AgentLoop — 项目的心脏
  • 12. 第四层:配置系统
  • 13. 第五层:REPL 交互
  • 14. 测试:Fake 比 Mock 更好
  • 15. 文件清单
  • 16. 下一站
Back to top

Related Posts

View all posts
Index 21: Terminal UX —— Claude Code 风格终端交互

Index 21: Terminal UX —— Claude Code 风格终端交互

August 21st, 2026

Index 21 将 cat-code 终端交互升级为 Claude Code 风格,解决旧 REPL 黑箱、无中断及输入体验差的问题。通过 JLine3 与 Mordant 实现历史补全、实时工具可见性、Spinner 状态及 Esc 中断。核心采用 UI 与 AgentLoop 解耦的事件流架构,支持内联权限菜单与优雅降级。同时配置 logback 收敛控制台日志,确保 TUI 清爽且功能无损,显著提升可用性。

Index 20: Comprehensive Agent —— 全机制集成(收口)

Index 20: Comprehensive Agent —— 全机制集成(收口)

August 13th, 2026

作为收官之作,把 s01-s19 的二十个核心机制整合为一个全面智能体:统一的状态查询与运行周期、自治认领与工作区隔离的贯通,以及 /status 命令的全局可视化。全机制协同运转,标志着 Cat-Code 从零完整复刻 Claude Code 核心能力的收官。

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

Index 19: MCP Plugin —— 多传输 / 通道路由 / 工具池组装

August 13th, 2026

引入 MCP(Model Context Protocol)插件机制,通过多传输适配与通道路由,把外部 MCP 服务器的工具动态接入智能体的工具池。工具注册从静态编译期扩展为运行时动态组装,让智能体能力随外部服务即插即用。