用 ~500 行 Python,把「一个 AI Agent 到底是怎么跑起来的」拆开讲清楚。 面向初学者:不需要 API key,不需要装依赖,clone 完就能跑。
中文 | English
不用注册,不用 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」来的,别从框架代码读起,从这五个文件读起。 每个都能单独运行,每个只引入一个新概念,每个开头都写清楚了 「上一步留下了什么问题,这一步来解决它」。
| 步骤 | 文件 | 新增了什么 | 读完你会明白 |
|---|---|---|---|
| 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/"]
第 4 步会当场演示「有记忆」和「无记忆」的区别 —— 同一句
「把上一步的结果写到 note.txt」,前者写进去的是 20,后者写进去的是
把上一步的结果写到 note.txt。Memory 不神秘,它就是一个没被丢掉的 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(["最终回答"])
messages 由 agent/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编辑 .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 |
# 安装 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 清空对话历史。
| 变量 | 默认值 | 说明 |
|---|---|---|
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 模型务必保持 auto 或 on |
ReAct = Reasoning + Acting。一个 Agent 每一轮都在做三件事:
- Reason — LLM 读取对话上下文,决定「下一步做什么」
- Act — 如果 LLM 决定用工具,就真的调用那个 Python 函数
- 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: 最终回答
上面跑完之后,messages 长这样 —— 这个数组就是 Agent 的全部「记忆」:
下一圈把这整个数组原样再发一次,模型就「看见」了 60。 没有增量,没有状态同步——每一圈都是把全部历史重新讲一遍。
每个工具两部分:
- Schema(OpenAI function calling 格式)— 告诉 LLM 这个工具怎么用、参数是什么。模型只能看到这段文字,所以
description写得好坏直接决定成败 - Handler(Python 函数)— 真正执行逻辑,接收
dict,返回str
注册工具只要一行:
register_tool(schema_dict, handler_function)这是初学者最容易忽略的一点:工具的参数来自模型,而模型的输入可能来自任何人。
所以本项目的工具里有一堆看起来「多余」的检查:
计算器不用 eval、文件工具用 realpath 而不是 abspath、
递归删除必须显式声明。这些不是洁癖,是 Agent 工具的必修课。
tests/test_file_io.py 里每一条都对应一个真实的逃逸手法。
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: 对话历史会越来越长,怎么办?
Memory 有 max_messages=50 的窗口裁剪,而且是按整轮删的。
这里有个隐蔽约束:tool 消息必须紧跟在带 tool_calls 的 assistant 之后。
按条数硬删就会踩中:
┌──── 这两条是一对,不能拆 ────┐
[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!
- ReAct 论文:ReAct: Synergizing Reasoning and Acting in Language Models
- Ollama — 本地跑 LLM 的最简方案
- Qwen — 阿里开源的高质量中文模型

[ { "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 直接报错 ]