目标
如果你用过 Claude Code,你一定熟悉这个循环:在终端输入需求,智能体调用 LLM 思考,然后执行工具、返回结果。这个循环是一切的基础——没有它,后面所有的工具调用、权限审批、多智能体协作都无从谈起。
Index 01 的目标就是实现这个循环的最简版本。具体来说,我们要做三件事:
- 定义一个 LLM 抽象接口,让 AgentLoop 不依赖具体的 API 实现。今天接 Anthropic,明天换 OpenAI,只需要新增一个实现类。
- 实现 REPL 交互,用户可以在终端持续对话,支持
/help、/clear、/exit等命令。 - 建立分层架构和测试策略,这 15 个文件将成为后续 19 个 Index 的地基。
完成后的效果如下——创建 .env 文件,一行启动:
$ 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! 🐾
通过 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 生态中每个领域都有多个选择,这里列出我们选的方案和理由:
// 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 个源文件组成,按依赖方向分为五层。上层依赖下层,下层不知道上层的存在:
┌──────────────────────┐
│ 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 + ConfigResolver | 114 + 71 | .env 解析 + 四级优先级(CLI > env var > .env > 默认值) |
| 入口 | CatCodeCommand | 110 | clikt 参数定义 + 依赖对象组装 |
| 终端 | ReplLoop | 123 | readLine 循环 → 命令分流 → println 输出 |
| 智能体 | AgentLoop | 111 | 消息历史管理 → sanitize → LLM 调用 |
| API | AnthropicProvider | 207 | HTTP POST → 双格式 JSON 解析 → stop_reason 映射 |
这种分层的实际好处是:每一层都可以独立测试。测试 AgentLoop 时,我们注入一个不联网的 FakeLLMProvider;测试 AnthropicProvider 时,我们用 MockEngine 拦截 HTTP 请求。没有任何测试需要真实的 API Key 或网络连接。
第一层:数据模型
任何项目都是从"数据长什么样"开始的。我们定义了三个基础类型,它们会被所有其他模块引用。
Message — 对话中的最小原子
enum class Role { SYSTEM, USER, ASSISTANT }
data class Message(val role: Role, val content: String)Role 枚举和 Message 放在同一个文件里。它们是一个概念的两面——Role 描述"谁说的",Message 描述"说了什么"。拆成两个文件会让每次使用都多一行 import,且阅读时需要在两个文件之间跳转。
ChatOptions — 每次 LLM 请求的可调参数
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 — 决定循环的下一步
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 — 整个项目最重要的接口
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 的响应格式是:
{
"id": "msg_001", "type": "message", "role": "assistant",
"content": [{"type": "text", "text": "Hello!"}],
"stop_reason": "end_turn"
}而 OpenAI 兼容 API(包括 DeepSeek 的 Anthropic 端点)返回的是:
{
"id": "chatcmpl-001", "object": "chat.completion",
"choices": [{"message": {"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}]
}我们的处理策略是先尝试 Anthropic 格式,解析失败则 fallback 到 OpenAI 格式。关键代码:
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 都要围绕展开的核心。
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 封装四级优先级逻辑。每个配置键一行调用:
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:
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 中,我们会把它替换为更精细的协程作用域管理,让多个工具可以并发执行。
内部命令目前支持四个——所有命令名都是常量,避免拼写错误:
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,比真实网络调用快三个数量级:
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:
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(...) 不仅更冗长,还泄露了实现细节(调用次数、参数匹配器)。
| 层 | 被测类 | 技术 | 用例数 |
|---|---|---|---|
| 数据 | Message | Kotest | 5 |
| 配置 | EnvFile | 临时文件 | 8 |
| 配置 | ConfigResolver | Kotest | 10 |
| API | AnthropicProvider | MockEngine | 7 |
| 逻辑 | AgentLoop | FakeLLMProvider | 7 |
文件清单
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"的循环中真正运转起来


