原文作者从零实现一个 Zig coding agent「Violin」,用它拆解现代 coding agent 的核心构件。本文保留正文、关键伪代码与协议示例;原文中的架构图、工具清单和部分代码截图以图片资源链接保留。
从 24 年冬天开始,各类 coding agent 层出不穷——从 Gemini CLI 的起步,到 Claude Code 的尝试,再到如今 Codex、OpenCode、PI、Qoder 等百花齐放。
短短一年多的时间,agent 从大模型的一个附属品,逐渐演变成模型能力的放大器,成为 AI 工程化的关键载体。市面上各类业务 agent——客服、数据分析、工作流编排等——追根溯源,基本都是 coding agent 的泛化变种。
理解了 coding agent 的构建原理,也就掌握了理解其他 agent 的一把钥匙。笔者对每天都在使用的 agent 原理很感兴趣,于是决定自己写一个:Violin。
虽然有点丑,但是基本的功能(多模态、skill、插件)已经完备了。
Violin 的架构设计深度借鉴了 Pi 的设计理念。Pi 作为一个 TypeScript 实现的 AI coding agent,其最突出的特点是简洁而可扩展的架构:三层分离(模型适配层 / 内核层 / 产品层)、EventBus 事件驱动、工具注册表和插件系统,每个模块各司其职且松耦合。
Pi 的代码完全开源,不仅让 Violin 有了一个扎实的参考蓝本,也让它本身成为一个 Agent 工程学习范本:代码结构清晰、注释详尽、每层职责明确。作者推荐通过 how-pi-agent-works 学习 Pi 架构。
Violin 保持这一架构精髓,同时用 Zig 替换 TypeScript,在内存安全和性能上做进一步探索。
既然每一层通过接口或网络协议解耦,不同层使用不同语言是可行的,不需要也不可能全部用一种语言写完。
不同模型 API 对工具调用、推理内容、缓存、错误、OAuth、流式协议的表达都不同。Violin 把这些差异统一成 Message、Tool、AssistantMessageEvent 和 streamSimple(),上层 Agent Loop 不需要知道是 Anthropic 的 tool_use 还是 OpenAI Responses 的 function call,只处理统一后的 toolCall 内容块。
内核层只负责 Agent 的运行循环,不关心消息从哪里来,也不关心执行结果存到哪里。
写一个 Agent Loop 不难,难的是把它变成每天能用的开发工具。产品层负责会话历史加载和保存、上下文压缩后的重试、Arena 内存管理等麻烦但关键的事情。
为了让客户端实现与语言无关,需要把 agent 能力包装为通信协议和 TCP Server。TCP Server 具备流式通信和全双工能力,适合这个场景。
Agent Loop 是一个 while 循环,在「问模型」和「执行工具」之间来回切换,直到模型给出最终答案。它不关心模型是 OpenAI 还是 Anthropic,也不关心工具是读文件还是跑命令,只关心两件事:模型要不要调工具;如果要,调完继续问,如果不要,结束。
核心伪代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
while (turn < max_turns) : (turn += 1) {
const assistant = try model.complete(.{
.messages = messages.items,
.tools = tool_registry.definitions(),
});
const has_tool_calls = assistant.toolCalls().len > 0;
if (!has_tool_calls) break;
for (assistant.content) |block| {
if (block == .tool_call) {
const result = tool_registry.execute(tc.name, tc.args);
messages.append(result); // 回写给模型
}
}
}
需要注意:
max_turns 是安全阀,防止模型陷入无限工具循环。messages,模型需要看到结果才能决定下一步。tools 定义要传给 complete(),模型必须提前知道可用工具。Agent Loop 只关心循环。会话历史的加载和保存、上下文压缩后的重试、Arena 内存管理由产品层 product/agent.zig 封装:它调用 loop.run() 时传入历史消息,返回后把新消息持久化到 Session,并在 ContextOverflow 时截断上下文后重试。
模型适配层的职责是把不同 LLM Provider 的 API 差异封装在一个 Model.complete() 接口后面。Agent Loop 只认这个接口,不关心背后是 OpenAI 还是 Anthropic。
1
2
3
4
5
6
7
8
9
10
11
12
13
interface ModelAdapter {
complete(input: CompleteInput) -> AssistantMessage
name() -> string
}
struct CompleteInput {
system_prompt: string
messages: Message[]
tools: ToolDefinition[]
max_tokens: int
temperature: float
stream_callback: optional callback
}
Zig 没有 trait 和虚函数,实际实现使用函数指针表。每个适配器提供 complete、name、deinit 三个函数指针,通过统一接口调用;适配器内部状态(如 base_url、api_key)通过类型擦除的指针在回调中还原。
Violin 适配了两种主流 LLM 协议。两个适配器都要处理 JSON 序列化、HTTP 请求、SSE 流式解析和错误映射,差异主要在:
messages[],Anthropic 使用 content[]。tool_calls[],Anthropic 使用 content[] 中的 tool_use block。data: 行,Anthropic 使用 event: 行。为了避免用户等待完整回答,Violin 采用 SSE 流式输出:模型适配器收到每个 chunk 后调用 stream_callback,Agent Loop 通过 EventBus 发射 message_update,Client 收到事件后实时追加到终端显示。
模型配置不内置在程序中,而是从 ~/.violin/agent/models.json 加载,再由客户端决定使用哪个模型:
1
2
3
4
5
6
7
8
9
10
11
12
{
"providers": {
"openai": {
"base_url": "https://api.openai.com/v1",
"api": "openai-completions",
"api_key": "$OPENAI_API_KEY",
"models": [
{"id": "gpt-4o", "name": "GPT-4o", "contextWindow": 128000}
]
}
}
}
模型适配层让 Agent Loop 可以调用任何模型,工具系统让 Agent Loop 可以做任何事。
工具定义包含名称、描述、JSON Schema 参数和执行函数:
1
2
3
4
5
6
pub const Tool = struct {
name: []const u8,
description: []const u8,
parameters: []const u8, // JSON Schema
execute: ToolExecuteFn,
};
Violin 内置 6 个基本工具,和 Pi 的设计保持一致。工具注册表使用 HashMap 按名称存储工具;definitions() 把工具列表编码为模型可识别的 JSON Schema,execute() 按名称找到工具并执行。
1
2
3
4
register(name, description, execute_fn)
get(name) -> Tool
definitions() -> ToolDefinition[]
execute(name, args) -> ToolResult
product/agent.zig 只有 123 行,却把整个项目粘起来:
loop.run() 执行。ContextOverflow,调用 compaction 压缩后重试。1
2
3
4
5
6
7
8
9
history = load_history(config.session)
while True:
loop_result = loop.run(model, history, user_input)
if loop_result == ContextOverflow:
compact_session()
continue
for msg in loop_result.new_messages:
session.appendMessage(msg)
return AgentResult(text=loop_result.final_text)
没有 Session,Agent 每次对话都是失忆的。Violin 使用 JSONL 存储会话:第一行是会话头(id、created_at、cwd、model),后续每行是一条消息。消息通过 parent_id 组成树结构。
SessionStore 包含:
file_path:JSONL 文件路径。entries:按 ID 索引消息,支持随机访问。leaf_id:当前叶子节点。next_id:生成新消息 ID。header:会话头信息。实际 Zig 实现使用 ArenaAllocator 统一管理内存,deinit 时一次释放。写入时先序列化成 JSON 行并写临时文件,再追加到会话文件;恢复时遇到损坏消息行会记录日志并跳过,不让整个会话崩溃。Pi 的 Session 也是 JSONL + 树结构,支持分支 fork 和回滚,Violin 继承了这些能力。
当 token 超过阈值时,把旧消息压缩成一条摘要,保留最近 N 条消息。默认阈值是 100K token,保留最近 10 条消息,摘要目标长度为 500 token。
为了避免引入 tokenizer,Violin 用字符数除以 4 近似 token 数:
1
2
3
pub fn estimateTokens(text: []const u8) usize {
return text.len / 4;
}
压缩前是 [消息1] [消息2] ... [消息N],压缩后是 [摘要:之前讨论的要点] [消息N-9] ... [消息N]。旧消息会被拼接后交给模型生成摘要,再与最近消息一起继续执行 loop。
resources.zig 从文件系统加载项目规则和技能,解析 frontmatter,并格式化成 system prompt 注入 LLM,让模型知道可用工具和能力。
项目规则优先级从高到低:
1
2
3
4
{cwd}/AGENTS.md
{cwd}/CLAUDE.md
~/.violin/agent/AGENTS.md
~/.violin/agent/CLAUDE.md
技能路径是项目先、全局后,同名冲突时项目赢:
1
2
3
{cwd}/.agent/skills/*/SKILL.md
{cwd}/.agents/skills/*/SKILL.md
~/.violin/agent/skills/*/SKILL.md
每个 Skill 包含名称、描述、文件路径、来源(global/project)和原文内容。SKILL.md 的 YAML frontmatter 会被解析并构造成 XML,注入到系统提示词的 <available_skills> 中。
Agent Loop 运行时,外界需要知道它进行到哪一步。事件系统让 Agent Loop 在开始一轮、生成 token、调用工具时向 EventBus 发事件,关心事件的组件注册回调。
作者最终选择 Lua 作为插件语言,不是因为 Lua 最好,而是因为它是最小的正确选择:约 500KB 运行时、长期嵌入场景成熟、可以通过 C 函数调用,适合 Zig 项目。
EventBus 有三个回调槽:agent、session、compaction。install() 保存原回调并换成自己的 dispatch 包装函数:先执行原回调(例如把流式结果写给客户端),再遍历已注册 Lua 插件,逐个调用 hook。
示例插件 bash-guard:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
return {
name = "bash-guard",
version = "0.2.0",
description = "拦截危险 bash 命令,自动加安全前缀",
on_tool_start = function(event)
if event.tool_name == "bash" then
if event.arguments:find("rm -rf", 1, true) then
return { action = "block", reason = "危险命令已阻止" }
end
return { action = "modify", arguments = "set -e; " .. event.arguments }
end
end,
on_context = function(event)
return { action = "modify", inject_text = "使用 bash 时注意安全", inject_role = "system" }
end,
}
插件可以在工具执行前阻止或修改参数、在工具执行后修改结果、在 LLM 调用前注入系统指令,也可以阻止 Agent 启动或手动压缩会话。
网络层定义 Violin 客户端与服务端的通信:客户端发送消息,服务端把思考过程、工具调用和最终回答逐条推送。
大多数 Coding Agent 是一体式进程,Agent 引擎、UI、会话、模型和工具都在一起。Violin 选择前后端分离:
1
2
一体式:[agent + UI + 会话] —— 一个本地进程
Violin:[Zig 服务端 daemon] <— TCP/JSON-Lines —> [Python TUI 客户端]
这个设计受到 ACP 协议启发,但由于 ACP 复杂且 Zig 缺少成熟实现,Violin 这个 toy 项目选择了更小、更容易实现的 TCP + JSON Lines。只要把通信协议交给 AI,也可以用其他语言实现客户端。
核心协议包括:
1
2
3
4
5
6
// 客户端 -> 服务端
{"type":"handshake","cwd":"/home/user/project/violin"}
// 服务端 -> 客户端
{"type":"models_result","models":[...],"default":"deepseek-v4-flash"}
{"type":"skills_result","global_skills":[...],"project_skills":[...]}
cwd 用于加载 {cwd}/.agent/skills/ 并注入系统提示词。
1
{"type":"chat","content":"列出目录下文件","model":"deepseek-v4-flash"}
可选字段包括 session_id、temperature、max_tokens、system_prompt 和 images。
Violin 设计了 8 个事件类型来支持完整对话:turn_start、delta、tool_start、tool_end、turn_end、result,以及错误和保活相关的 error、ping。
一个完整流大致是:客户端发送 chat;服务端发 turn_start 和上下文用量;连续发 delta;工具执行前发 tool_start,执行后发 tool_end;继续发 delta;最后发 turn_end 和 result。
Python Client 的核心是 async TCP 连接和事件分发器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ViolinClient:
def __init__(self, host="127.0.0.1", port=9877):
self.reader = None
self.writer = None
self.models = []
self.session_id = ""
self.on_delta = None
self.on_tool_start = None
self.on_tool_end = None
self.on_result = None
async def connect(self, retries=3):
for i in range(retries):
try:
self.reader, self.writer = await asyncio.open_connection(*self.addr)
await self._send({"type": "handshake", "cwd": os.getcwd()})
msg = await self._read_msg()
if msg and msg.get("type") == "models_result":
self.models = msg.get("models", [])
return True
except ConnectionRefusedError:
await asyncio.sleep(0.5 * (2 ** i))
return False
事件分发器持续读取 socket,按 type 调用 on_delta、on_tool_start、on_tool_end 和 on_result 回调;收到 error 返回错误,收到 ping 则发送 pong。
coding agent 的核心没有什么魔法。剥开花哨的 UI 和功能,底层就是一个 while 循环:问模型、拿结果、判断要不要调工具、调完再问。Violin 的每一层都是在给这个循环补齐工程能力:
这个玩具距离成熟 Coding Agent 还有未填的坑:buildJson 里的 tools 参数还没有序列化,模型根本收不到工具定义;插件没有权限隔离,Lua 可以做任何事;ACP 协议也没有接入,暂时只能自己跟自己玩。
但作为一个从零搭起来的 toy agent,项目的目的不是交付商用产品,而是验证一个判断:理解 coding agent 的构建原理,也就掌握了理解其他 agent 的一把钥匙。
模型统一适配、工具注册与调度、会话持久化与恢复、上下文压缩与保留、插件注入与拦截,看似互不相干,底层都收敛到同一个循环:问模型、调工具、再问。客服 agent 的会话管理、数据分析 agent 的工具链编排、工作流 agent 的状态机设计,都是这个循环在不同场景下的变形。
剩下的坑既是项目当前的边界,也是下一段探索的起点。把 toy 项目一路补到能真正落地,过程本身就是学习方式。
原文包含 22 张图片/截图,以下保留资源地址,供回看架构图、工具清单和代码截图:
标签:#主题/AI-Agent #主题/AI-Coding #主题/Agent-Loop #主题/Agent-架构 #主题/Skill #主题/插件系统 #节点/Agent-Loop #节点/模型适配 #节点/Tool-System #节点/Session #节点/Context-Compaction #节点/EventBus #节点/TCP-JSON-Lines #场景/公众号长文