发布时间:
Claude Code、Cursor 这类工具,能自己读文件、改代码、跑命令,报错了还会回头再改。我一直想知道里面到底是怎么转的,于是用 Go写了个简化版,跑通了"读写文件 + 执行命令"。
写完发现,核心没多少东西,就是一个循环。
Agent 和聊天机器人的区别 #
普通对话是一问一答,模型只说不做。它没法在你电脑上建文件,也不知道你项目里有什么。
Agent 给模型加了工具:读文件、写文件、执行命令。流程就变成:
模型:先看下这个文件 → read_file
程序:返回文件内容
模型:第 30 行有问题,改一下 → write_file
程序:写入成功
模型:跑下测试 → execute_command
程序:测试报错,空指针
模型:再改 → write_file
程序:……
模型:好了,测试通过
(模型不再调工具,结束)
用什么工具、传什么参数、什么时候算干完,都是模型自己定的。代码只负责执行工具、把结果返回给它。
结构 #
项目分五层:
cmd/:读用户输入,维护消息列表internal/agent/:核心循环,调模型、分发工具internal/tool/:读写文件、执行命令internal/models/:消息和工具的 JSON 结构internal/session/:每轮对话落盘
一轮对话的流转:
用户输入
↓
main 往消息列表加一条 user,调用 agent.Loop
↓
agent.Loop:
把消息 + 工具清单 POST 给模型
模型返回 tool_calls?
有 → 逐个执行,结果塞回消息列表,再问模型
没 → 这就是最终回答,结束
核心循环 #
剥掉细节,Agent 就是下面这段:
messages = [系统提示:你是谁、有哪些工具、什么环境]
messages.append(用户输入)
for 最多 10 次: // 兜底,防止死循环
resp = 模型.chat(messages, 工具清单)
if resp 带 tool_calls:
messages.append(模型这次的 tool_calls) // 记下它想调工具
for call in resp.tool_calls:
名字 = call.function.name
参数 = call.function.arguments
结果 = 工具注册表[名字](参数) // 本机执行
messages.append(tool 消息, 结果, id=call.id)
continue // 带结果再问一次
else:
输出 resp.内容 // 没有工具调用 = 任务完成
break
整个循环就一个判断:模型这次回包带没带 tool_calls。带了就执行、喂回去、继续转;没带就是答案,退出。
Claude Code 比这复杂得多,但最里面那层循环是同一个东西。
三个关键点 #
1. 工具是一份说明书,不是函数。
模型不会真的调你的函数,它只会输出文本。你要用 JSON Schema 把工具描述给它:
{
"name": "read_file",
"description": "读取指定文件路径内容",
"parameters": {
"properties": {
"path": {
"type": "string",
"description": "文件绝对路径"
}
},
"required": [
"path"
]
}
}
模型看完,想读文件时就回一段 JSON:我要调 read_file,path 是 C:/a.txt。函数怎么实现它不管。描述写清楚,模型选工具、填参数才准。
2. 结果怎么喂回去。
工具执行完,用一条 role: "tool" 的消息塞回历史,带上 tool_call_id 跟那次调用对上:
messages = append(messages, models.Message{
Role: "tool",
ToolCallId: call.Id,
Content: 结果,
})
下一轮模型就知道:我上次让它读 a.txt,它返回了这些。靠这个 id 配对,模型才能基于真实反馈往下推。
3. 工具分发表。
用一个 map 登记工具,来调用就查表:
var toolRegistry = map[string]ToolHandler{
"read_file": readFile,
"write_string_file": writeStringFile,
"execute_command": executeCommand,
"kill_process": killProcess,
}
handler := toolRegistry[call.Function.Name]
content := handler(call.Function.Arguments)
加工具就两步:写个函数、登记进表。
几个坑 #
Windows 上真跑起来,有几个地方得处理。
中文乱码。 tasklist、wmic 这类命令输出是 GB18030,当 UTF-8 读就是乱码,模型拿到没法用。我的做法:有 BOM 剥掉,是合法
UTF-8 直接用,否则按 GB18030 解码。
常驻进程不能傻等。 npm start、npm run dev 启动后一直跑,普通方式执行会永远阻塞。所以分两种模式:run 等命令跑完返回输出
(比如 ipconfig、npm install);spawn 只启动,立刻返回 PID,超时也不杀进程,让服务在后台跑,要停再用 kill_process(底层
taskkill /F /T /PID)。
参数类型不靠谱。 JSON 反序列化后参数全是 map[string]any,数字可能是 float64,布尔可能是 "true"
字符串。读参数得做类型兜底,裸断言容易 panic。
每步落盘。 用户消息、工具调用、工具结果、最终回答,都按 JSON Lines 写到 conversation/时间戳.jsonl。出问题能完整回放,调试时很有用。
跑起来 #
命令行里用自然语言下指令:
user > 在 D:\demo 下建个 hello.txt,写"你好 Agent",再读出来确认
后台日志:
[1] execute_command 看目录在不在
exit code: 0
[2] write_string_file 创建并写入
Write bytes: 20
[3] read_file 读回验证
你好 Agent
agent > 已创建,内容确认是"你好 Agent"
下载&安装 #
- 仓库地址:https://github.com/systemmin/first-agent/
- releases:https://github.com/systemmin/first-agent/releases
- go 命令直接安装
go install github.com/systemmin/first-agent/cmd/first-agent@latest
使用方式 #
| 命令行参数 | 环境变量 | 默认值 | 说明 |
|---|---|---|---|
-conversation |
FIRST_AGENT_CONVERSATION |
./conversation |
会话记录目录 |
-ollama |
FIRST_AGENT_OLLAMA_URL |
http://localhost:11434/api/chat |
Ollama 聊天接口地址 |
-model |
FIRST_AGENT_MODEL |
glm-4.7-flash:q4_K_M |
模型名 |
-max-iterations |
~ | 10 | 最大循环调用工具次数 |
如果有 Go 环境可以直接执行:
go run github.com/systemmin/first-agent/cmd/first-agent@latest