ki - 核心设计
ki 核心设计
Agent Loop
主要入口是 runTurn,由 RpcServer 和 Subagent 调用,当用户输入一条 query 时正式开启 agent loop。
主要有以下流程:发起事件 -> 处理会话标题(新会话专属,旧会话跳过) -> 等待 loop 完成 -> 处理异常(如果有的话)
等待 loop 完成 中的 #loop 才是内部主要运行主体,根据 llm 的工具意图作为循环依据,主要流程为:开始循环 -> 是否压缩 -> 构建 context -> 流式调用 llm -> 处理工具意图 -> 继续循环。
构建 context
#buildRequest 方法用于构建 context,主要拼接:系统提示 + 历史消息 (含工具调用/结果/压缩) + 完整工具信息 + 模型 + 中断信号 + 思考预算。
这个方法做了什么:
1 | return { |
#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 | for (const tu of toolUses) { |
循环 await #executeTool 方法,异步调用工具。但是,该方法只做一层代理,真正的逻辑是内部的 executeToolUse 方法。
其内部大致分为以下阶段:查找工具 -> 验证权限 -> 用户确认 -> 工具真实执行 -> 落盘到 session。
压缩
渐进式压缩,什么时候压缩?如果用户调用 /compact 命令,或者当前上下文占用超过窗口的 85%,就会触发压缩。压缩判断会发生在构建 context 之前。
如果进入压缩,分三个阶段,只有前一个阶段完成后还是超阈值 85%,才会进入下一个阶段。
工具结果驱逐:让 runtime 忽略区间内的全部工具结果。
结构化归档:将旧消息归档为摘要,保留最近的对话内容。
如果归档后仍然超出阈值,就会将所有归档合并为压缩摘要,按照一定的主题划分为若干摘要。
兜底截断:如果归档失败,就会直接截断早期内容,保留最近的对话内容,并存入占位符,提示用户早期内容已省略。
结构化归档
把 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 | { |
先澄清一个事实,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 | { |
只有当发生归档时 archives 才有值。而发生结构化归档和合并式归档时,archives 的值也不一样。
发生结构化归档,和上一小节中的一致,几乎把每一轮都做了归档,存到 text 里,如下:
1 | "archives": [ |
而如果发生合并式归档,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 中。
