DEV Community

goodlords
goodlords

Posted on

从零手写 Agent(一):Tool 把我从 Chat 拽进了 Loop

从零手写 Agent(一):Tool 把我从 Chat 拽进了 Loop

最近开始自己折腾 Agent。

一开始我也没急着上 LangGraph 之类的框架,而是在本地用 Ollama 写了一个最小版本:一个 calculator,一个 while 循环,再加上 OpenAI 兼容的 Tool Calling。

代码其实没多少,但跑通以后,我反而觉得这件事比看框架源码有意思。

因为真正把 Calculator Agent 跑起来之后,我才发现:

Agent 和普通 Chat 的区别,并不是 Prompt 写得更长,而是模型开始参与“决定下一步做什么”。

而 Tool,就是这个变化真正发生的地方。

这篇不讲框架,也不打算把 Agent 的概念从头到尾讲一遍。主要记录写这个最小 Agent 时遇到的几个问题,以及跑日志以后对 Tool、Loop、State 的一些理解。

系列目录:GitHub docs(https://github.com/zealot2002/myagent/blob/main/docs/00-系列导读.md) · 代码见文末仓库链接。


1. 一开始,我以为 Agent 就是 Prompt 写得更复杂

普通 Chat 很简单:

User → LLM → Answer
Enter fullscreen mode Exit fullscreen mode

所以我最开始对 Agent 的理解也很朴素:

不就是在 Prompt 里告诉模型:“你可以使用这些工具”吗?

真自己跑起来以后,发现完全不是这么回事。

一个最简单的 Agent,实际执行过程更像这样:

User
  ↓
LLM
  ↓
需要工具?
  ├─ 否 → Answer
  │
  └─ 是
       ↓
    Tool Call
       ↓
    执行 Tool
       ↓
    Observation
       ↓
    LLM
       ↓
    Answer / 再调 Tool
Enter fullscreen mode Exit fullscreen mode

换成代码,其实就是一个非常普通的循环:

while step < MAX_STEPS:
    response = llm(messages, tools=tools)

    if response.has_tool_call:
        assistant_turn = ...
        result = execute_tool(response.tool_call)

        messages.append(assistant_turn)
        messages.append(tool_result_turn)
    else:
        return response.text
Enter fullscreen mode Exit fullscreen mode

到这里,我才真正理解了之前经常看到的那句话:

Agent ≈ LLM + Tools + Loop + State

以前看这句话的时候,感觉多少有点像概念总结。

自己写一遍以后就不一样了。

所谓 Loop,本质就是:

模型说下一步想干什么 → 程序真的去干 → 把结果塞回来 → 再让模型决定下一步。

这才是 Agent 开始“动起来”的地方。


2. 那 Tool 到底是什么?

这个问题我一开始也容易和 Function Call 混在一起。

后来我觉得可以用一句比较简单的话理解:

Tool = 给模型看的说明书 + 程序里真正执行的函数。

这里其实有两个角色。

模型负责:

  • 要不要调用 Tool
  • 调哪个 Tool
  • 参数应该怎么填

程序负责:

  • 接收模型给出的参数
  • 真正执行函数
  • 把执行结果返回给模型

比如我们有一个:

calculator(expression)
Enter fullscreen mode Exit fullscreen mode

模型可能会输出:

{
  "expression": "123 * 456 + 789"
}
Enter fullscreen mode Exit fullscreen mode

注意,这时候计算还没有发生。

模型只是说:

“我想调用 calculator,而且参数是这个。”

真正执行:

calculator("123 * 456 + 789")
Enter fullscreen mode Exit fullscreen mode

的是我的 Python 程序。

所以 Tool Calling 这件事,可以简单理解成:

LLM
 ↓
“我要调用 calculator,参数是 xxx”
 ↓
你的程序
 ↓
真的执行 calculator
 ↓
返回结果
 ↓
LLM
Enter fullscreen mode Exit fullscreen mode

这点很重要。

因为模型发出来的 Tool Call,本质上只是一个请求或者提议,不是程序真的执行了什么。

参数也不一定永远正确。

所以 Agent Loop 不能把模型输出当成绝对可靠的函数调用,必须有一层真正的执行逻辑接住它。


3. 第一个 Calculator Agent 跑起来以后,我才知道 Loop 在干什么

我先问一个最简单的问题:

123 * 456 + 789 是多少?
Enter fullscreen mode Exit fullscreen mode

模型用的是 qwen2.5:3b。

实际日志:

INFO [joyjoy] agent step 1/10
INFO [joyjoy] tool call: calculator({'expression': '123 * 456 + 789'})
INFO [joyjoy] tool result: 56877
INFO [joyjoy] agent step 2/10

计算结果是 56877。
Enter fullscreen mode Exit fullscreen mode

第一次看到这个日志,我觉得挺有意思。

因为它其实把 Agent 的工作过程完整暴露出来了。

第一步:

User
↓
LLM
↓
calculator("123 * 456 + 789")
Enter fullscreen mode Exit fullscreen mode

第二步:

calculator
↓
56877
↓
LLM
↓
“计算结果是 56877”
Enter fullscreen mode Exit fullscreen mode

所以这里出现两个容易误解的地方。

第一,为什么要跑两步?

因为第一轮模型的任务不是“说人话”,而是决定:

这个问题需要调用 calculator。

于是第一轮主要产生的是:

tool_call
Enter fullscreen mode Exit fullscreen mode

工具执行以后,模型拿到 56877,第二轮才负责组织最终回答。

所以:

两步不是多余的,而是 Tool Calling 的正常流程。

第二,真正完成计算的是谁?

不是 LLM。

是:

calculator
Enter fullscreen mode Exit fullscreen mode

LLM 做的是理解问题,然后决定:

calculator("123 * 456 + 789")
Enter fullscreen mode Exit fullscreen mode

程序负责真正计算。

这也是我觉得 Tool 很有价值的地方:

让模型负责“理解”,让程序负责“确定性”。

计算器只是一个很简单的例子。

如果换成搜索、数据库、文件系统、API,逻辑其实还是一样。


4. 模型到底怎么知道“现在应该调 Tool”?

这个问题我刚开始也想得比较简单。

我以为背后是不是有一个东西:

用户问题
↓
关键词检测
↓
出现“计算”
↓
调用 calculator
Enter fullscreen mode Exit fullscreen mode

实际上不是。

模型看到的是:

  • system prompt
  • 用户问题
  • 历史 messages
  • Tool 的 description/schema

然后自己决定这一轮应该输出普通文本,还是 tool_calls。

比如:

123 * 456 + 789 是多少?
Enter fullscreen mode Exit fullscreen mode

它可能选择:

tool_calls → calculator
Enter fullscreen mode Exit fullscreen mode

而:

Agent 是什么?
Enter fullscreen mode Exit fullscreen mode

大概率直接:

assistant → 普通文本
Enter fullscreen mode Exit fullscreen mode

这里有一个很容易低估的东西:

Tool description。

我现在越来越觉得,Tool description 有点像给模型看的 API 文档。

如果你有几个功能非常相似的 Tool,description 写得含糊,模型很容易选错。

所以 Tool 的名字固然重要,但真正影响模型选择的,往往是:

Tool 是干什么的
什么时候应该用
什么时候不要用
参数是什么
返回什么
Enter fullscreen mode Exit fullscreen mode

尤其是小模型。

我用的只是本地 qwen2.5:3b,它并不是每次都能做出最合理的选择。

所以实际调 Agent 的时候,我现在比较习惯先看 trace,而不是先怀疑 Loop。


5. 模型明明会算,为什么还要 Calculator?

这个问题也挺有意思。

比如:

123 * 456 + 789
Enter fullscreen mode Exit fullscreen mode

模型自己其实很可能也能算出来。

那为什么还要 Tool?

我的理解是:

很多时候我们要的不是“模型觉得答案是多少”,而是一个可以验证、可以记录、可以复现的结果。

计算尤其明显。

让 LLM 负责:

理解题目
Enter fullscreen mode Exit fullscreen mode

让程序负责:

计算
Enter fullscreen mode Exit fullscreen mode

最后得到的结果就比较容易验证。

同样的思路放到其他场景里也成立:

查数据库 → 数据库负责事实
搜索网页 → 搜索服务负责事实
调用 API → API 返回真实状态
执行操作 → 程序负责副作用
Enter fullscreen mode Exit fullscreen mode

所以 Tool 的意义并不是:

“模型不会做,所以给它一个工具。”

更准确一点应该是:

模型适合做判断和规划,但某些事情交给确定性的程序更靠谱。


6. 一个很有意思的坑:没有 Tool,Agent 就不会回答了吗?

然后我遇到了一个挺有意思的问题。

当时 Agent 里只有一个:

calculator
Enter fullscreen mode Exit fullscreen mode

我突然问:

今天是几号?
Enter fullscreen mode Exit fullscreen mode

结果模型没调 Tool,而是直接说:

请提供当前日期或使用可以获取当前日期的服务或工具。
我目前无法直接获取当前日期。
Enter fullscreen mode Exit fullscreen mode

当时第一反应是:

Agent 是不是坏了?

后来仔细看了一下,Loop 根本没问题。

这一轮发生的是:

LLM
↓
没有 tool_call
↓
返回普通文本
↓
Loop 结束
Enter fullscreen mode Exit fullscreen mode

问题其实在另外一个地方:

Agent 根本没有获得“当前时间”这个能力。

这让我意识到一个很容易混淆的问题:

Agent 能不能完成任务,和 Loop 写得对不对,是两件事。

Loop 只负责:

模型 → Tool → 模型 → Tool → ...
Enter fullscreen mode Exit fullscreen mode

至于模型有没有能力完成当前任务,取决于你给它什么。


7. “今天”到底从哪里来?

这个问题我后来又专门拆了一下。

如果没有:

  • 当前时间
  • 时间 Tool
  • 其他环境信息

那就不能指望模型凭空知道“今天”。

模型没有一块隐藏的硬件时钟。

我现在这个最小 Agent 里,采用的是另一种简单办法:

直接把当前时间写进 system prompt。

比如:

【系统环境】当前本地时间:
2026-10-22 10:54:36 +0800
(日期以 YYYY-MM-DD 为准)
Enter fullscreen mode Exit fullscreen mode

所以进入 Loop 时,messages 大概是:

messages = [
    {
        "role": "system",
        "content": "【系统环境】当前本地时间:2026-10-22 ..."
    },
    {
        "role": "user",
        "content": "今天是几号?"
    }
]
Enter fullscreen mode Exit fullscreen mode

然后模型只是读取上下文里的这段文字。

它并没有真的跑到我的电脑上读取系统时间。

这一点和 Tool 有一个很直观的区别:

System 注入 Tool
信息在哪里 system.content role: tool
是否需要额外 Loop 不需要 通常需要
谁提供 宿主程序 Tool 执行结果
例子 当前时间 查询数据库

这个区分对我来说挺重要。

因为以前容易产生一种误解:

“只要 Agent 有 Tool,所有外部信息都应该通过 Tool 获取。”

其实没必要。

一些稳定、基础的环境信息,直接注入 system 就够了。

而需要动态查询、执行或者验证的东西,再考虑 Tool。


8. 同一个问题,为什么有时候结果还不一样?

把当前时间注入 system 以后,再问:

今天几号?
Enter fullscreen mode Exit fullscreen mode

情况就开始变得有意思了。

理想情况下:

INFO [joyjoy] agent step 1/10

2026-10-22
Enter fullscreen mode Exit fullscreen mode

一步结束。

但小模型偶尔也会犯迷糊。

比如出现:

INFO [joyjoy] tool call:
calculator({'expression': 'today().day'})

WARNING [joyjoy] tool calculator failed:
unsupported expression: Attribute

INFO [joyjoy] tool result:
Error: unsupported expression: Attribute

INFO [joyjoy] agent step 2/10

今天是2026-10-22……
Enter fullscreen mode Exit fullscreen mode

也就是说,它先误用了 calculator,发现不行以后,再根据 system 里的时间把答案答出来。

甚至还有更极端的情况:

模型在 content 里自己输出了一段看起来像 JSON 的东西,但协议层面并没有真正产生 tool_calls。

这种情况下,Loop 只能把它当普通文本处理。

这也让我对一个问题有了比较现实的认识:

Agent 不是写完 Loop 就万事大吉。

实际效果还和下面几个东西强相关:

模型
Tool description
System prompt
输出协议
上下文
Enter fullscreen mode Exit fullscreen mode

所以调 Agent 的时候,我现在更愿意看真实 trace。

不要光盯着最终答案。


9. Tool 一多,问题就来了

目前我的 Calculator Agent 只有一个 Tool,所以事情还很简单。

但如果继续加:

calculator
search_web
read_file
query_database
get_current_datetime
...
Enter fullscreen mode Exit fullscreen mode

模型就需要从一堆 Tool 里做选择。

这时候我目前比较认可几个原则。

Tool 的职责要尽量清楚

比如:

search_web
Enter fullscreen mode Exit fullscreen mode

负责搜索网页。

那就别再搞一个:

web_search
Enter fullscreen mode Exit fullscreen mode

两者干一样的事情。

对模型来说,这相当于:

“这里有两个看起来差不多的按钮,你自己猜按哪个。”

description 比名字更重要

Tool 的 description 最好直接说清楚:

负责什么
什么时候用
什么时候不要用
参数怎么传
Enter fullscreen mode Exit fullscreen mode

把它当 API 文档写就行。

Tool 不是越多越好

协议层面可以注册很多 Tool。

但真正的问题是:

模型到底能不能稳定选对?

如果一个 Agent 同时挂几十个功能,模型选择本身就开始变成问题。

到了这个阶段,通常应该考虑 Router、多 Agent 或者 Tool 分组,而不是继续往一个 Agent 里塞。


10. 我现在调 Agent,基本就按这个顺序排查

这一点是我写完 Calculator 之后最大的一个变化。

以前遇到:

Agent 答错了
Enter fullscreen mode Exit fullscreen mode

第一反应可能是:

Loop 有 bug?

现在我会先看 trace,然后把问题分成几类:

1. 漏调 Tool
2. 误调 Tool
3. 没有合适 Tool
4. Tool 自己执行失败
5. 模型输出协议有问题
6. 上下文/Prompt 有问题
7. 真正的代码 bug
Enter fullscreen mode Exit fullscreen mode

然后一次只改一个地方,再重新跑。

比如模型不调 calculator:

先看 Tool description
Enter fullscreen mode Exit fullscreen mode

模型乱调 calculator:

看 description + system + 模型能力
Enter fullscreen mode Exit fullscreen mode

Tool 调对了,但执行失败:

查 execute_tool
Enter fullscreen mode Exit fullscreen mode

模型明明应该知道时间却不知道:

查 system 环境信息
Enter fullscreen mode Exit fullscreen mode

这样排查起来,比一上来修改 while 循环有效得多。


11. 还有一个和 Agent 完全无关的坑:代理

最后记录一个比较无聊,但确实浪费我时间的问题。

接 Ollama 的时候,OpenAI SDK 报:

502 Bad Gateway
Enter fullscreen mode Exit fullscreen mode

但我直接:

curl localhost:11434
Enter fullscreen mode Exit fullscreen mode

却是正常的。

最后发现不是 Agent,也不是 Tool,更不是 Ollama 挂了。

是 macOS 的 HTTP 代理把 localhost 也转发出去了。

把客户端的:

trust_env
Enter fullscreen mode Exit fullscreen mode

处理好之后才恢复正常。

所以有时候 Agent 看起来“挂了”,真的不一定是 Agent 的问题。

特别是本地模型这种环境,网络代理、endpoint、环境变量这些东西也得一起查。


12. 收束

注意:

  • Tool = Schema + 你的实现;执行永远在进程里,不在模型权重里。
  • Agent ≈ Loop:LLM → 是否 tool_calls → 执行 → 结果进 messages → 再调 LLM。
  • 何时调 Tool 由问题、历史、system、Tool description 和模型能力共同决定 — description 要写准。
  • 缺能力(如当前日期)应加 Tool/上下文,而不是改 Loop 逻辑糊弄过去。
  • --trace 比架构图直观:看清 assistant(tool_calls) → tool → assistant 最终答。

建议路径:先手写几十行 Loop + 一个 Calculator,故意跑坏几次,再去看框架。


下一篇

《从零手写 Agent(二):上下文窗口与多轮对话》——messages 就是短期记忆;-i 与同进程 State;Context Window 与 Ollama 静默截断。

系列目录:GitHub docs(https://github.com/zealot2002/myagent/blob/main/docs/00-系列导读.md)


附录:几个关键日志

计算题

INFO [joyjoy] agent step 1/10
INFO [joyjoy] tool call: calculator({'expression': '123 * 456 + 789'})
INFO [joyjoy] tool result: 56877
INFO [joyjoy] agent step 2/10

计算结果是 56877。
Enter fullscreen mode Exit fullscreen mode

--trace

[1] user: 123 * 456 + 789 是多少?
[2] assistant: tool_calls → calculator({"expression":"123 * 456 + 789"})
[3] tool: 56877
[4] assistant: 计算结果是 123 乘以 456 再加 789 等于 56877。
Enter fullscreen mode Exit fullscreen mode

没有时间信息时

INFO [joyjoy] agent step 1/10

请提供当前日期或使用可以获取当前日期的服务或工具。
我目前无法直接获取当前日期。
Enter fullscreen mode Exit fullscreen mode

调优后误调 Calculator

INFO [joyjoy] tool call:
calculator({'expression': 'today().day'})

INFO [joyjoy] tool result:
Error: unsupported expression: Attribute

INFO [joyjoy] agent step 2/10

今天是2026-10-22,……
Enter fullscreen mode Exit fullscreen mode

代码:

https://github.com/zealot2002/myagent

Top comments (0)