开发者-导航 猿导航

我用 Go 手搓了一个能写代码的 Agent

发布时间:

Claude Code、Cursor 这类工具,能自己读文件、改代码、跑命令,报错了还会回头再改。我一直想知道里面到底是怎么转的,于是用 Go写了个简化版,跑通了"读写文件 + 执行命令"。

写完发现,核心没多少东西,就是一个循环。

Agent 和聊天机器人的区别 #

普通对话是一问一答,模型只说不做。它没法在你电脑上建文件,也不知道你项目里有什么。

Agent 给模型加了工具:读文件、写文件、执行命令。流程就变成:


模型:先看下这个文件        → read_file
程序:返回文件内容
模型:第 30 行有问题,改一下 → write_file
程序:写入成功
模型:跑下测试              → execute_command
程序:测试报错,空指针
模型:再改                  → write_file
程序:……
模型:好了,测试通过
(模型不再调工具,结束)

用什么工具、传什么参数、什么时候算干完,都是模型自己定的。代码只负责执行工具、把结果返回给它。

结构 #

项目分五层:

一轮对话的流转:

用户输入
  ↓
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,pathC:/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 上真跑起来,有几个地方得处理。

中文乱码。 tasklistwmic 这类命令输出是 GB18030,当 UTF-8 读就是乱码,模型拿到没法用。我的做法:有 BOM 剥掉,是合法
UTF-8 直接用,否则按 GB18030 解码。

常驻进程不能傻等。 npm startnpm run dev 启动后一直跑,普通方式执行会永远阻塞。所以分两种模式:run 等命令跑完返回输出
(比如 ipconfignpm 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"

下载&安装 #

使用方式 #

命令行参数 环境变量 默认值 说明
-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