让 AI 自己决定调哪个工具:最小 Agent 长什么样(第一期)
Harness = 6 件套
任何 Agent 框架(Anthropic / OpenAI / LangChain / Hermes Agent)核心层都叫 Harness(驾驭层)——把 LLM 圈进"可控循环"。
Gartner 2026 预测:40% Agentic AI 项目到 2027 年底将被取消——死因是 Harness 没做对。
Harness = 6 件套:
| 件 | 干啥的 |
|---|---|
| Loop | 控制循环(while + 工具调用 + 退出条件) |
| Tool | 工具注册 + 执行 |
| Context | 上下文(system/messages/tools) |
| Memory | 短期/中期/长期记忆 |
| Permission | 沙箱(文件/Shell/工具/成本) |
| Eval | 评估(准确率/成本/延迟/安全/可解释) |
本系列从 0 造——12 期走完你就有完整 Harness 生产版,不依赖任何框架。
本期做 1 件:Loop
第一期只演示 6 件套中的 1 件:Loop——AI 决定"调哪个工具 + 啥时候停"。
| 缺什么 | 怎么补 | 期 |
|---|---|---|
| 多工具 (Tool Registry) | 4 工具 → 5 工具 | 第三期+ |
| 错误处理 (retries/ctx) | 分层骨架 | 第二期 |
| 持久记忆 | SQLite + RAG | 第八期 |
| 权限沙箱 | os/exec 受限 | 第十期 |
| 评估体系 | 5 维度 runner | 第十一期 |
| 多 Agent 协作 | chan 消息队列 | 第十期 |
最小可跑 Agent 长这样
下面只截 3 段关键逻辑说明 Agent 怎么跑:
片段 1:工具声明(9 行)
var calculator = map[string]any{
"type": "function", "function": map[string]any{
"name": "calculator", "description": "计算 2 个数的加减乘除",
"parameters": map[string]any{"type": "object", "properties": map[string]any{
"a": map[string]any{"type": "number"},
"op": map[string]any{"type": "string"},
"b": map[string]any{"type": "number"},
}, "required": []string{"a", "op", "b"}},
},
}
告诉 LLM "你有 calculator 可用"。
片段 2:调 LLM(10 行)
func call(msgs []msg) (*msg, error) {
body, _ := json.Marshal(map[string]any{
"model": "gpt-4o-mini", "messages": msgs,
"tools": []any{calculator},
})
req, _ := http.NewRequest("POST", baseURL+"/chat/completions", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENAI_API_KEY"))
resp, _ := http.DefaultClient.Do(req)
defer resp.Body.Close()
var out struct{ Choices []struct{ Message msg } `json:"choices"` }
json.NewDecoder(resp.Body).Decode(&out)
return &out.Choices[0].Message, nil
}
HTTP POST + JSON 解析。没有任何花活。
片段 3:循环 + 工具回灌(15 行)
hist := []msg{{Role: "user", Content: "2+3*4 等于多少?"}}
for i := 0; i < 5; i++ {
m, _ := call(hist)
hist = append(hist, *m)
if len(m.ToolCalls) == 0 { fmt.Println(m.Content); break }
for _, tc := range m.ToolCalls {
var args struct{ A float64; Op string; B float64 }
json.Unmarshal([]byte(tc["function"].(map[string]any)["arguments"].(string)), &args)
var r float64
switch args.Op { case "+": r = args.A+args.B; case "*": r = args.A*args.B }
hist = append(hist, msg{Role: "tool", Content: strconv.FormatFloat(r, 'f', -1, 64), ToolCallID: tc["id"].(string)})
}
}
这段是 Agent 灵魂——while + 调 LLM + 看 tool_calls + 执行工具 + 回灌消息。
4 件事 = 任何 Agent 框架的核心:
| 件 | 行 | 干啥的 |
|---|---|---|
for i := 0; i < 5 |
1 | Loop: while 循环 |
body, _ := json.Marshal(...) |
3 | Loop: 调 LLM |
if len(m.ToolCalls) == 0 |
1 | Loop: 退出条件 |
var calculator = map[string]any{...} |
9 | Tool: 工具声明 |
for _, tc := range m.ToolCalls |
7 | Loop: 执行 + 回灌 |
json.Unmarshal(...) + switch args.Op |
8 | Tool: 工具执行 |
剩 27 行是 JSON 结构体定义(type msg struct, type chatResp struct)——不是核心逻辑。
跑起来
export OPENAI_API_KEY=sk-xxx
go run .
期望输出:
20
完整执行轨迹(用户看到的是):
[轮 1] LLM 决定调 calculator(2, "+", 3) = 5
[轮 2] LLM 决定调 calculator(5, "*", 4) = 20
[轮 3] LLM: 2+3*4 = 20
退出
任意 OpenAI 兼容 API 都行(Anthropic / MiniMax / DeepSeek),改
baseURL即可。
Loop 的退出条件
| 条件 | 触发 |
|---|---|
| LLM 不再调工具 | len(m.ToolCalls) == 0 退出 |
| 达到 maxTurns=5 | 防止死循环 |
| 用户 Ctrl-C | 本版不支持(第二期补) |
关键字段(Context 视角)
| 字段 | 属于哪件 | 作用 |
|---|---|---|
tools |
Context | 告诉 LLM "你有 calculator 可用" |
tool_calls |
Loop 信号 | LLM 决定 "我要调 calculator" |
role: "tool" |
Memory + Loop | 把 "5" 塞回对话 |
tool_call_id |
Memory 关联 | 关联到具体哪次 tool_call |
4 个缺一不可。
最小版 ≠ "能上生产"
| 缺什么 | 期 |
|---|---|
| 错误处理(4 层包装) | 第二期 |
| 多文件分层(main/agent/llm/tools) | 第二期 |
| 全链路 ctx(Ctrl-C 取消) | 第二期 |
| 6 个 flag 配置 | 第二期 |
| 2 个单元测试 | 第二期 |
| slog 结构化日志 | 第二期 |
| 多工具(5 个 + Registry) | 第三期 |
| 持久记忆(SQLite + 轻 RAG) | 第八期 |
| 权限沙箱(os/exec 受限) | 第十期 |
| 评估体系(5 维度 runner) | 第十一期 |
第二期把这 6 件 Loop 增强全补了——单文件 → 5 模块分层,但仍只覆盖 1 件 Harness:Loop。
12 期路线图
| 期 | 类型 | Harness 件 | 主题 | 行数 |
|---|---|---|---|---|
| 第一期 | 教学 | Loop | 本文:最小可跑 Agent | — |
| 第二期 | 实战 | Loop | 分层骨架(入口 + 业务) | — |
| 第三期 | 教学 | Tool | Tool Registry 模式 | — |
| 第四期 | 实战 | Tool | 5 工具完整实现 | — |
| 第五期 | 教学 | Context | 4 要素拆解 | — |
| 第六期 | 实战 | Context | 多轮对话 + 压缩 | — |
| 第七期 | 教学 | Memory | 3 层模型 | — |
| 第八期 | 实战 | Memory | SQLite + 轻 RAG | — |
| 第九期 | 教学 | Permission | 4 维沙箱 | — |
| 第十期 | 实战 | Permission | os/exec 受限 | — |
| 第十一期 | 教学 | Eval | 5 维度评估 | — |
| 第十二期 | 实战+收官 | Eval | GopherAgent 1.0 | — |
进度:█░░░░░░░░░░░░░░░░░░░░ 1/12 (8.3%)
下期
第二期 GopherAgent 0.1 骨架——把本期单文件扩到分层结构,加错误处理 + ctx + 测试 + 6 个 flag + slog。Loop 这一件从"能跑"升级到"能上测试环境"。
夜雨聆风