ki 核心设计

Ki

Agent Loop

主要入口是 runTurn,由 RpcServer 和 Subagent 调用,当用户输入一条 query 时正式开启 agent loop。

主要有以下流程:发起事件 -> 处理会话标题(新会话专属,旧会话跳过) -> 等待 loop 完成 -> 处理异常(如果有的话)

等待 loop 完成 中的 #loop 才是内部主要运行主体,根据 llm 的工具意图作为循环依据,主要流程为:开始循环 -> 是否压缩 -> 构建 context -> 流式调用 llm -> 处理工具意图 -> 继续循环。

构建 context

#buildRequest 方法用于构建 context,主要拼接:系统提示 + 历史消息 (含工具调用/结果/压缩) + 完整工具信息 + 模型 + 中断信号 + 思考预算。

这个方法做了什么:

1
2
3
4
5
6
7
8
9
10
11
12
return {
system: this.#buildSystemPrompt(),
messages: this.#toProviderMessages(),
tools: this.opts.tools.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
})),
model: this.opts.model,
signal: this.abort.signal,
thinking: { enabled: this.thinkingLevel !== "off", budget: THINKING_BUDGETS[this.thinkingLevel] ?? 0 },
};

#buildSystemPrompt():系统提示词,包含模型人设、指导原则、可用工具(name+简要用途snippet)、可用skill、CLAUDE.md&AGENTS.md内容、当前工作目录等信息

#toProviderMessages():将 session.jsonl 文件内的消息转换为 Provider 消息格式,包含用户消息、助手消息、工具调用和工具结果,也包括工具调用的摘要信息。另外,这一方法也排除了压缩前的归档消息,避免模型看到过多的历史内容。

tools(一个 lambda 表达式):给出工具完整信息,name+description+inputSchema,因为 ki 的原生工具数量很少,直接给模型完整信息,避免模型去额外查找工具用途。

model、signal、thinking:字面意思。

流式调用 llm

流式调用 llm 用的是异步生成器,const stream = this.#streamWithRetry(request); 返回一个异步生成器,后面再用循环 for await 这个生成器,就能监听其流式片段了。

监听时,分清片段种类,加入到对应阵营中就行了。四种阵营:text、thinking、tool_use、usage。

最后落盘到 session。

工具调用/回填

判断 if (toolUses.length === 0) 是否需要执行工具,同时决定 loop 是否继续进行。

如果需要,则:

1
2
3
for (const tu of toolUses) {
await this.#executeTool(turnId, tu, assistantMessageId);
}

循环 await #executeTool 方法,异步调用工具。但是,该方法只做一层代理,真正的逻辑是内部的 executeToolUse 方法。

其内部大致分为以下阶段:查找工具 -> 验证权限 -> 用户确认 -> 工具真实执行 -> 落盘到 session。

压缩

渐进式压缩,什么时候压缩?如果用户调用 /compact 命令,或者当前上下文占用超过窗口的 85%,就会触发压缩。压缩判断会发生在构建 context 之前。

如果进入压缩,分三个阶段,只有前一个阶段完成后还是超阈值 85%,才会进入下一个阶段。

  1. 工具结果驱逐:让 runtime 忽略区间内的全部工具结果。

  2. 结构化归档:将旧消息归档为摘要,保留最近的对话内容。

  3. 如果归档后仍然超出阈值,就会将所有归档合并为压缩摘要,按照一定的主题划分为若干摘要。

  4. 兜底截断:如果归档失败,就会直接截断早期内容,保留最近的对话内容,并存入占位符,提示用户早期内容已省略。

结构化归档

把 user/assistant 的消息归纳为结构化语句,比如 轮次 N: 意图=...; 动作=...; 结论=...; 文件=...。

另外,在此阶段,上一个阶段中保留的短工具结果会彻底失效,因为不再从未归档的 message 里读取消息了,唯有长工具结果在 view_state 中的 retained 结果能够被读取。

原因是,长结果的调用成本高,保留其有用部分可以有效避免多次调用长结果,性价比最高。而短结果,可以认为其调用成本很低,随时可以重新调用。

记忆

ki 是 coding agent,不具备通用或垂直型 agent 的严谨的记忆系统,也没有短期记忆/长期记忆之分,所有记忆来源都是 session.jsonl 文件。

session.jsonl 文件位于 .ki/session/ 目录下,每个工作空间包含若干文件夹,每个文件夹就是一个会话,里面包含一个 session.jsonl 文件,内部以 SessionEntry 为最小单位存储消息。

不过,llm 自身已经有这样的能力:在项目推进过程中,在项目目录中维护 .md 格式的结构化笔记,作为全局记忆固定下来。

未完成的:我们预留了 tree 型 session 结构需要的 parentId 等信息,以便后续实现 fork 等功能。

session 的数据结构

SessionEntry 的通用格式为:

1
{ "id": "<uuid>", "ts": 时间戳, "kind": 消息类型, 根据 kind 决定后续有多少字段 }

八种类型:

类型 用途
user push 一条 user 消息
assistant push 一条 assistant 消息,toolUses 变成 toolCalls[],初始 status=queued
tool_result 找最近一条 assistant 里 id 匹配的 toolCall,回填 result,status → done/failed/rejected;找不到就丢弃
usage 累加进会话总用量
context 覆盖 lastInput(取最后)
view_state / summary 覆盖压缩视图(取最后;旧条目缺 files 按空索引处理)
taint 按 kind+label 去重后并入污染来源
meta 更新标题(取最后一条)

压缩后的 session

ki 的 view_state 专门用来处理这种情况,它是在上下文压缩后拼接到 session 后面,用来告知 runtime “你应该把哪些内容放到上下文里”,并且只有最后一条 view_state 生效。

其结构是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"id":"",
"ts":,
"kind":"view_state",
"archives": [
{ "from": 0, "to": 164, "text": "轮1-2 问ki及能力→自述桌面编程助手…" },
{ "from": 164, "to": 219, "text": "轮次 1: 意图=演示 MCP 功能; 动作=调用 test 服务器 13 工具; 结论=12 成功…" }
],
"evictBefore": 219,
"files": {
"read": [".", "ARCHITECTURE.md", "core/src/session/session.ts", … 共 45 项],
"modified": ["verify-test.txt", "p0-test2.txt", "C:/Users/a1829/.ki/skills/…/SKILL.md", … 共 7 项]
}
}

先澄清一个事实,0、164、219 是什么?并非消息在 session.jsonl 中的行数,而是 user、assistant 消息在内存列表中的下标,内存列表是从 session.jsonl 中读取的,session.jsonl 又是 append-only 的,这意味着已有的消息在任何情况下读取到内存列表中,下标都不变,所以基于这个事实我们的 view_state 可以永久生效。

archives:告诉 runtime 0 到 164 的消息和 164 到 219 的消息分别被归档,而划分原则是根据区间内消息总占用 token 数划分。

evictBefore:219 以后的消息保留。

压缩三大阶段与 view_state

渐进式压缩的三大阶段并非一定连续,如果只发生工具结果驱逐,那么就只有 evictBefore 有值,负责归档的 archives 为空,比如:

1
2
3
4
5
6
7
{
"id":"2814c324-b6d5-4a4e-96a2-1ec9cb182bd0",
"ts":1789367919273,
"kind":"view_state",
"archives":[],
"evictBefore":142
}

只有当发生归档时 archives 才有值。而发生结构化归档和合并式归档时,archives 的值也不一样。

发生结构化归档,和上一小节中的一致,几乎把每一轮都做了归档,存到 text 里,如下:

1
2
3
4
"archives": [
{ "from": 0, "to": 164, "text": "轮1-2 问ki及能力→自述桌面编程助手…" },
{ "from": 164, "to": 219, "text": "轮次 1: 意图=演示 MCP 功能; 动作=调用 test 服务器 13 工具; 结论=12 成功…" }
],

而如果发生合并式归档,archives 的形式和结构化归档一致,只是 text 来源于总结,和旧的 summary 一致,极大减缓了。

工具

ki 的工具系统,手动实现了数个工具,可支持 mcp 工具接入,同时允许 codemode。

tool 是怎么执行的

三大基类:Tool、ToolContext、ToolResults,我们手动实现的所有工具都是 Tool 类对象,包含 execute 方法供外部调用。

在后续的 loop 进程中,检测到工具意图需要执行时,归根结底就是调用工具的 execute 方法。

有两个特殊工具 run_code 和 subagent,分别是 codemode 和 subagent 的最初入口,我们将其设置为与一般工具一样走通用接口。

mcp

基于 json rpc 协议,符合协议规则的请求响应都可以接入 ki 作为 mcp server 提供 tools 能力。

两种连接方式:本地 stdio(ki 唤起 mcp server 子进程)、远程方式(基于 http 协议的两个独立进程相互调用)

未完成的:只实现了 tools,resources、prompts 等未实现。

安全

ki 的安全主要针对工具调用,当前有两个安全相关的包 security 和 perm,分别承担判断本次调用是否危险和判断用户是否放行。

主要入口在 tool-exec.ts 内的 executeToolUse 方法,分别判断目标文件(不管是读写)是否在安全路径内、调用的工具是否具备权限、用户是否放行,只有三大门控全部通过才能成功执行。

路径安全:主要看目标文件是否在当前工作空间内,agent 从能力上看可以访问整个文件系统内的全部路径,但是我们规定工作空间以外的路径 agent 不能访问,这是为了数据安全。

工具权限:针对路径安全与否,我们赋予只读的工具可以随时读取的能力,毕竟不会直接写文件造成不可逆损害。但是也有数据泄露的问题。

用户确认:碰到写工具需要在工作空间以外的路径工作,需要前端向用户确认。

Codemode

codemode 作为工具提供给 llm,入口是 run_code 工具,能够编写 ts 代码以字符串形式捕获并组织成 .ts 代码,随后通过用户本地的 tsx 环境执行代码并回传结果。

当调用 run_code 工具,传导给 codemod/bridge 包下的 CodeModeRunner.run 方法,又调用 executor.executeCode 方法,该方法首先找到 tsx 加载器用于加载代码,随后在 “ki-codemode-“ 临时文件夹内写入临时代码文件,执行后,将结果形式化再一步步返回给 loop,并回填到 session。

未完成的:支持使用代码编排 tool,虽然这个在 pi 内也属于平常关闭的功能。

Subagent

subagent 作为工具提供给 llm,入口为 subagent 工具,对外提供 execute 方法,其内部又是调用了 subagent/runner 下的 run 方法。

底层实现,const child = new AgentLoop(··· 创建了新的 agent loop 对象用于容纳 subagent 的任务,注意在注入工具式过滤了 subagent 防止嵌套调用,随后调用其 runTurn 方法,和主 agent 的 loop 没有任何区别。

Subagent 的上下文,核心原则是形式上与主 agent 一致,但是不归档到本地。subagent 期间,所有的 message、usage 等 session entry 全部存在内存里,当 subagent 进程结束后随 gc 消失。

Subagent 唯一归档本地的是其核心结论,作为工具结果存到对应的 message 中。