Skip to content

Repository files navigation

MiniAgent

用 ~500 行 Python,把「一个 AI Agent 到底是怎么跑起来的」拆开讲清楚。 面向初学者:不需要 API key,不需要装依赖,clone 完就能跑。

CI Python License

中文 | English

终端演示:git clone 之后直接 python main.py,无需 API key 和依赖,Agent 依次调用 calculator 和 file_io 完成任务


30 秒跑起来

不用注册,不用 API key,连 pip install 都不用:

git clone https://github.com/TingdeLiu/miniagent.git
cd miniagent
python main.py "计算 (12+8)*3 然后写入 result.txt"

你会看到一个 Agent 完整地想了三轮、用了两个工具:

[Step 1] 调用 LLM...
[Act]     调用工具 calculator({"expression": "(12+8)*3"})
[Observe] 60
[Step 2] 调用 LLM...
[Act]     调用工具 file_io({"action": "write", "filename": "result.txt", "content": "60"})
[Observe] 已写入 result.txt(2 字符)
[Step 3] 调用 LLM...
[Answer] (DEMO 模式的模板回答)任务「计算 (12+8)*3 然后写入 result.txt」已按规则执行完毕。
         工具依次返回:60 → 已写入 result.txt(2 字符)

这一步用的是假模型。 默认的 demo 模式不连任何 LLM, 而是用 agent/demo_llm.py 里几十行硬编码规则「假装」在推理。 这么做只有一个目的:让你在配环境之前,先把 Agent 的数据流看明白。 接真实模型 只需要改 .env 里的一行。


五步手写一个 Agent

如果你是冲着「搞懂 Agent」来的,别从框架代码读起,从这五个文件读起。 每个都能单独运行,每个只引入一个新概念,每个开头都写清楚了 「上一步留下了什么问题,这一步来解决它」。

步骤 文件 新增了什么 读完你会明白
1 step1_bare_llm.py LLM 只是「文本进、文本出」,它没有手脚
2 step2_first_tool.py 工具 模型自己不执行任何东西,它只会说「请帮我调用……」
3 step3_react_loop.py 循环 Reason → Act → Observe 转起来,就是 ReAct
4 step4_memory.py 记忆 所谓记忆,就是那个没被丢掉的 messages 列表
5 step5_multi_tool.py 注册表 让工具自己注册自己,主流程就不必认识任何具体工具
python tutorial/step1_bare_llm.py    # 然后 step2、step3……

每一步都是被上一步的具体痛点逼出来的,不是谁坐下来设计出一套优雅架构:

%%{init: {"flowchart": {"wrappingWidth": 560}}}%%
flowchart TD
    S1["step1 · 光秃秃的 LLM —— 它只会说话,算不准也读不了文件"]
    S2["step2 · 手动调一次工具 —— 四拍全摊开,但一次调用要发两次请求"]
    S3["step3 · ReAct 循环 —— 把那四拍包进 while,多个工具才连得起来"]
    S4["step4 · 记忆 —— messages 留着不丢,下一轮才记得住上一轮"]
    S5["step5 · 注册表 —— 工具自己注册自己,主流程不必认识它们"]
    S1 --> S2 --> S3 --> S4 --> S5
    S5 -.-> REAL["这五个概念加固之后,就是 agent/ 和 tools/"]
Loading

第 4 步会当场演示「有记忆」和「无记忆」的区别 —— 同一句 「把上一步的结果写到 note.txt」,前者写进去的是 20,后者写进去的是 把上一步的结果写到 note.txtMemory 不神秘,它就是一个没被丢掉的 list。

详见 tutorial/README.md,里面还有四道练习。


为什么做这个项目?

市面上的 Agent 框架(LangChain、LlamaIndex、AutoGen…)功能强大, 但抽象层太厚,初学者很难一眼看清 「一个 Agent 本质上是什么」

MiniAgent 的目标:不造轮子、不搞魔法,用最直白的代码讲明白 5 个核心概念。

核心概念 对应文件 一句话解释
Tool(工具) tools/ Agent 能「动手做」的外部能力(计算、搜索、读写文件…)
Memory(记忆) agent/memory.py 保存对话历史,让 Agent 有「上下文」
Planner(规划) agent/planner.py 把复杂目标拆成有序子任务
Executor(执行) agent/executor.py 把 LLM 输出的 tool_calls 派发给真正的函数
Loop(主循环) agent/loop.py ReAct 循环:Reason → Act → Observe

读完并亲手改一遍这 5 个文件,你就理解了现代 Agent 框架的骨架。 之后再看 LangChain / AutoGen 的源码,会发现它们本质上就是在这套骨架上加层、加特性。


架构一览

整个循环就是下面这张图。注意它有两个出口——一个是模型说完了,一个是撞上步数上限:

flowchart LR
    START(["用户目标"]) --> REASON{"Reason<br/>把完整 messages<br/>发给 LLM"}
    REASON -->|"带 tool_calls"| ACT["Act + Observe<br/>执行工具<br/>结果写回 messages"]
    ACT -->|"未到上限"| REASON
    ACT -->|"到 MAX_STEPS"| STOP(["中断"])
    REASON -->|"不带 tool_calls"| DONE(["最终回答"])
Loading

messagesagent/memory.py 保管,工具从 tools/registry.py 取。每转一圈 messages 就长一点, 模型掌握的信息就多一点 —— 这就是 Agent 能自己走完多步任务的全部秘密。

图里没画 Planner:它默认关闭,作用是在进入这个循环之前先把 goal 拆成几个子任务, 然后每个子任务各自走一遍上面的流程。开关是 USE_PLANNER

内置 3 个示例工具:

  • calculator — 数学计算。基于 AST 白名单,拒绝任意代码执行,也拒绝 9**9**9 这种能把进程拖死的表达式
  • search — DuckDuckGo 网页搜索(可选依赖)
  • file_io — 文件读写 / 追加 / 删除 / 建文件夹 / 移动。沙箱限制 + 防软链接逃逸 + 递归删除必须显式声明

目录结构

miniagent/
├── main.py                 # 入口:CLI 交互模式
├── config.py               # 配置:环境变量、LLM 客户端(惰性构造)
├── agent/
│   ├── loop.py             # ReAct 主循环
│   ├── planner.py          # 任务拆解(可选)
│   ├── executor.py         # 工具调度
│   ├── memory.py           # 对话历史 + JSON 持久化
│   └── demo_llm.py         # 零配置演示用的规则式假模型
├── tools/
│   ├── registry.py         # 工具注册表
│   ├── calculator.py       # 示例工具:安全计算
│   ├── search.py           # 示例工具:DuckDuckGo
│   └── file_io.py          # 示例工具:文件操作
├── tutorial/               # ★ 五步手写教程,建议从这里开始
├── tests/                  # 单元测试
├── requirements.txt        # 接真实 LLM 时才需要
└── .env.example            # 环境变量模板

接上真实模型

DEMO 模式的回答是规则拼出来的模板,看两遍就腻了 —— 这也是它的目的。 接真实模型分两步:装依赖,改一行配置。

pip install -r requirements.txt
cp .env.example .env          # Windows: copy .env.example .env

方式 A:云端 API(零部署,推荐新手)

编辑 .env

LLM_MODE=remote
LLM_REMOTE_URL=https://api.deepseek.com/v1
LLM_API_KEY=sk-你的真实key
LLM_MODEL=deepseek-chat

任何 OpenAI 兼容的服务都能用:

服务商 LLM_REMOTE_URL 获取 API Key
OpenAI https://api.openai.com/v1 https://platform.openai.com/api-keys
DeepSeek https://api.deepseek.com/v1 https://platform.deepseek.com/api_keys
阿里百炼(通义千问) https://dashscope.aliyuncs.com/compatible-mode/v1 https://bailian.console.aliyun.com/
月之暗面(Kimi) https://api.moonshot.cn/v1 https://platform.moonshot.cn/console/api-keys
智谱(GLM) https://open.bigmodel.cn/api/paas/v4/ https://open.bigmodel.cn/usercenter/apikeys
硅基流动 https://api.siliconflow.cn/v1 https://cloud.siliconflow.cn/account/ak

方式 B:本机 Ollama(离线、免费、隐私好)

# 安装 Ollama:https://ollama.com/download
ollama pull qwen3:8b     # 8B 约 5GB,需要 ≥16GB 内存;低配可换 qwen3:4b
ollama serve

编辑 .env

LLM_MODE=local
LLM_LOCAL_URL=http://localhost:11434/v1
LLM_MODEL=qwen3:8b

💡 小参数模型(≤7B)的 function calling 能力较弱,可能会忽略工具「硬答」。 如果发现 Agent 不调工具,优先换更大的模型或走方式 A。

运行

python main.py                                    # 交互模式
python main.py "帮我计算 (12+8)*3 然后写入 result.txt"  # 单次任务后进入交互模式

交互模式中:exit 退出,clear 清空对话历史。


配置项(.env)

变量 默认值 说明
LLM_MODE demo demo(假模型,零配置)/ local(本机 Ollama)/ remote(云端 API)
LLM_LOCAL_URL http://localhost:11434/v1 本地 Ollama 地址
LLM_REMOTE_URL 远程 LLM 服务地址(LLM_MODE=remote 时生效)
LLM_MODEL demo 下为 demo-llm,否则 qwen3:8b 模型名称
LLM_API_KEY ollama Ollama 不校验,占位即可;云端 API 填真实 key
MAX_STEPS 10 ReAct 最大轮数(防死循环),必须是正整数
MEMORY_PATH 对话历史持久化路径,留空则不持久化
USE_PLANNER false 是否启用 Planner 预先拆解任务
FILE_BASE_DIR 项目根目录 file_io 工具的沙箱根目录
LLM_THINK_MODE auto Qwen Thinking Mode:auto / on / off。非 Qwen 模型务必保持 autoon

核心概念解释

ReAct 循环是什么?

ReAct = Reasoning + Acting。一个 Agent 每一轮都在做三件事:

  1. Reason — LLM 读取对话上下文,决定「下一步做什么」
  2. Act — 如果 LLM 决定用工具,就真的调用那个 Python 函数
  3. Observe — 把工具返回结果塞回对话,让 LLM「看到」

循环直到 LLM 不再要求调用工具(视为最终回答),或达到 MAX_STEPS

有一个容易踩的坑:不要靠 finish_reason 判断结束。 不同服务商填法不一致,有的带着 tool_calls 也返回 "stop"。 只看 message.tool_calls 是否为空更可靠 —— 见 agent/loop.py 里的注释。

一次工具调用,消息到底是怎么走的?

这是初学者最容易误解的地方,值得单独看一遍。模型自己不执行任何东西

sequenceDiagram
    autonumber
    participant U as 你
    participant L as Loop
    participant M as Memory
    participant API as LLM
    participant T as 工具函数

    U->>L: 计算 (12+8)*3
    L->>M: 追加 role=user
    L->>API: 完整 messages + 工具说明书
    API-->>L: 要调用 calculator
    Note over API,L: 模型只是「说」它想调哪个函数<br/>它碰不到你的机器
    L->>M: 追加 role=assistant
    L->>T: 这一步才真的执行 Python
    T-->>L: 60
    L->>M: 追加 role=tool(用 tool_call_id 配对)
    L->>API: 再发一次完整 messages
    API-->>L: 这次没有 tool_calls,只有文字
    L-->>U: 最终回答
Loading

上面跑完之后,messages 长这样 —— 这个数组就是 Agent 的全部「记忆」

[
  { "role": "system",    "content": "你是一个智能助手,必须优先使用工具…" },
  { "role": "user",      "content": "计算 (12+8)*3" },

  { "role": "assistant", "content": "",
    "tool_calls": [{ "id": "call_1", "type": "function",
                     "function": { "name": "calculator",
                                   "arguments": "{\"expression\": \"(12+8)*3\"}" } }] },
  //                        ↑ 注意 arguments 是 JSON 字符串,不是 dict

  { "role": "tool", "tool_call_id": "call_1", "content": "60" }
  //                 ↑ 靠这个 id 和上面那条 assistant 配对,
  //                   而且必须紧跟在它后面,顺序错了 API 直接报错
]

下一圈把这整个数组原样再发一次,模型就「看见」了 60。 没有增量,没有状态同步——每一圈都是把全部历史重新讲一遍。

工具(Tool)是怎么工作的?

每个工具两部分:

  1. Schema(OpenAI function calling 格式)— 告诉 LLM 这个工具怎么用、参数是什么。模型只能看到这段文字,所以 description 写得好坏直接决定成败
  2. Handler(Python 函数)— 真正执行逻辑,接收 dict,返回 str

注册工具只要一行:

register_tool(schema_dict, handler_function)

工具的输入永远不可信

这是初学者最容易忽略的一点:工具的参数来自模型,而模型的输入可能来自任何人。

所以本项目的工具里有一堆看起来「多余」的检查: 计算器不用 eval、文件工具用 realpath 而不是 abspath、 递归删除必须显式声明。这些不是洁癖,是 Agent 工具的必修课。 tests/test_file_io.py 里每一条都对应一个真实的逃逸手法。

Thinking Mode 与 <think>

Qwen3 默认开启 Thinking Mode,输出里夹带 <think>...</think>(模型的内心独白)。 本项目在两处处理它:请求时用 extra_body={"think": False} 关闭, 响应时再用正则清洗残留,避免污染上下文。


扩展:写一个自己的工具

新建 tools/datetime_tool.py

from datetime import datetime
from tools.registry import register_tool


def _handler(args: dict) -> str:
    return datetime.now().strftime(args.get("format", "%Y-%m-%d %H:%M:%S"))


register_tool(
    {
        "type": "function",
        "function": {
            "name": "current_time",
            "description": "返回当前本地时间。问到时间必须用它,不要猜。",
            "parameters": {
                "type": "object",
                "properties": {
                    "format": {"type": "string", "description": "strftime 格式字符串"}
                },
            },
        },
    },
    _handler,
)

然后在 tools/__init__.py 里加一行 from . import datetime_tool。 重启后 Agent 就能用它了。就这么简单。


常见问题

Q: 为什么默认是「假模型」?我要的是真 Agent。

因为绝大多数人是在「还没配好环境」的状态下第一次打开这个项目的。 默认连 Ollama 的结果通常是一个连接错误,学习就断在了第一步。

demo 模式让你先看清数据流,再决定要不要花时间配环境。 改一行 .env 就是真的。

Q: 为什么我的模型经常忽略工具、自己瞎答?

小参数模型(7B 以下)的 function calling 能力较弱。建议:

  • 换 Qwen3:8b 及以上,或 GPT-4o-mini、DeepSeek-V3 等
  • 加强 main.py 里的 SYSTEM_PROMPT,明确「必须使用工具」
  • 把工具的 description 写得更具体、更强硬
  • 开启 USE_PLANNER=true,用 Planner 先把任务拆清楚
Q: Agent 陷入死循环怎么办?

已有 MAX_STEPS 保护(默认 10 轮)。可以在 .env 里调小。 想看它被中断的样子,把 tutorial/step3_react_loop.py 里的 max_steps 改成 1。

Q: 对话历史会越来越长,怎么办?

Memorymax_messages=50 的窗口裁剪,而且是按整轮删的。

这里有个隐蔽约束:tool 消息必须紧跟在带 tool_callsassistant 之后。 按条数硬删就会踩中:

              ┌──── 这两条是一对,不能拆 ────┐
[user] [assistant+tool_calls] [tool] [user] [assistant] …

❌ 按条数删最旧的两条
   → [tool] [user] [assistant] …
      ↑ 孤儿 tool 消息:它要配对的 assistant 没了
        API 返回 400,而报错信息完全看不出是这个原因

✅ 按整轮删
   → [user] [assistant] …
      配对关系整段搬走,怎么删都不会破

agent/memory.py_trim(),以及 tests/test_memory.py 里专门盯这件事的那条测试。

Q: file_io 工具会不会写坏我的系统?

不会。所有路径都被限制在 FILE_BASE_DIR(默认项目根目录)内: 绝对路径、../ 越界、软链接逃逸都会被拒绝,删除非空文件夹必须显式传 recursive=true,沙箱根目录本身永远删不掉。

Q: 我想贡献代码。

CONTRIBUTING.md。 标了 good first issue 的问题特别适合第一次提 PR。


开发

pip install -e ".[dev,search]"
pytest          # 46 个测试
ruff check .    # 代码风格

路线图

  • M1 — 单工具 ReAct(计算器能跑)
  • M2 — 多步任务(Planner 拆解)
  • M3 — 多工具(search + file_io)
  • M4 — Memory 持久化(重启恢复上下文)
  • M5 — 零配置 demo 模式 + 五步教程
  • M6 — 流式输出(边生成边打印)
  • M7 — 工具并行执行
  • M8 — Web UI(Gradio / Streamlit)

欢迎 PR!


许可

MIT

致谢

About

用 ~500 行 Python 讲清楚 AI Agent 是怎么跑起来的 · 零配置即可运行 | A minimal ReAct agent you can run with zero setup

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages