上一篇(《Agent 到底是什么?一个后端程序员的大白话翻译》)我们说:Agent 的本质就是一个 while 循环。有朋友看完表示:道理我都懂,但没见过真的。
行,今天就把这个循环真刀真枪写出来。不用 LangGraph,不用 CrewAI,不用任何框架——就用一个 SDK 加 Python 标准库,写一个能自己查数据库回答问题的 Agent。全部代码 196 行,含注释,文中的运行记录全部来自真实运行。
写完你会发现:那些框架宣传页上的黑话,突然全都看得懂了。
先看效果
下面是一次真实运行的记录(我们造了一张游戏充值订单表当演示数据):
注意三个细节:
我们没有告诉它表结构,它自己先调 get_schema 看了一眼
我们没有教它"要排除退款订单",它自己在 SQL 里加了 status = 'paid'
第 2 轮它一口气并行发出两条 SQL(已支付口径 + 全量口径对比),第 3 轮又按 status 分组交叉核对了一遍——连"复核"都是它自己决定要做的
这就是 Agent 和写死流程的区别:决定"下一步干什么"的是模型,不是你的 if-else。
准备工作
pip install openaiexportDEEPSEEK_API_KEY=你的key # 千万别把 key 写死在代码里python mini_agent.py "哪个游戏最赚钱?"
本文用 DeepSeek 演示(模型 deepseek-chat)——国内直连、便宜、注册就送额度,对动手党最友好。它走的是 OpenAI 兼容接口,所以装的 SDK 是 openai;Kimi、Qwen、GLM 等国产模型基本都兼容这套格式,换一家只需要改 base_url 和模型名。核心循环的逻辑和哪家模型无关——这正是这篇要讲的重点。
完整代码在文末,下面拆开讲三个关键部分。
第一部分:定义工具 = 写接口文档
给模型定义工具,本质上就是我们后端最熟悉的活儿——写接口文档:
TOOLS= [ {"type": "function","function": {"name": "get_schema","description": "查看数据库里所有表的建表语句(表结构)。回答任何数据问题前先调用它。","parameters": {"type": "object", "properties": {}}, }, }, {"type": "function","function": {"name": "query_db","description": "对 SQLite 数据库执行只读 SELECT 查询,返回 JSON 格式的结果行。只允许 SELECT,其他语句会被拒绝。","parameters": {"type": "object","properties": {"sql": {"type": "string", "description": "要执行的 SELECT 语句"}, },"required": ["sql"], }, }, },]name 是函数名,parameters 是参数定义(就是 JSON Schema,和你写 OpenAPI 文档一个格式),description 是给"调用方"看的说明。
只是这次的调用方不是隔壁组的同事,而是模型。所以 description 是整个工具定义里最重要的字段——模型完全靠它来决定什么时候调这个工具、怎么调。写得越像一份好的接口文档(说清楚"什么时候用"而不只是"这是什么"),模型用得越准。给同事写文档会偷懒,给模型写文档偷不了懒,因为它真的会一字一句照着理解。
对应的实现就是两个普通的 Python 函数,没有任何魔法:
defquery_db(sql: str) ->str:# 护栏:模型生成的 SQL 是不可信输入,只放行 SELECTifnotsql.strip().lower().startswith("select"):raiseValueError("只允许 SELECT 查询")conn=sqlite3.connect(DB_PATH)conn.row_factory=sqlite3.Rowrows=conn.execute(sql).fetchmany(100) # 护栏:最多返回 100 行conn.close()returnjson.dumps([dict(r) forrinrows], ensure_ascii=False)划重点:模型的输出是不可信输入。它生成的 SQL 要过白名单(只允许 SELECT)、要限制返回行数。这和你处理外部请求参数是同一种警惕——安全意识直接从后端平移过来。
第二部分:核心循环,就这 40 行
上一篇的伪代码,落地成真代码长这样:
defrun_agent(client: OpenAI, question: str) ->str:messages= [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": question}, ]forround_noinrange(1, MAX_ROUNDS+1):# 1. 把"任务 + 历史 + 可用工具"发给模型(它是无状态的,每次全量发)response=client.chat.completions.create(model=MODEL,messages=messages,tools=TOOLS, )message=response.choices[0].message# 2. 模型的回复原样追加进历史(含 tool_calls,一个都不能丢)messages.append(message)# 3. 模型觉得干完了(不再要求调工具),返回最终答案ifnotmessage.tool_calls:returnmessage.contentor"(模型没有返回文本)"# 4. 模型要调工具:逐个执行,每个调用回一条 tool 消息forcallinmessage.tool_calls:args=json.loads(call.function.argumentsor"{}")result=execute_tool(call.function.name, args)messages.append({"role": "tool","tool_call_id": call.id, # 必须回填这个 id,模型靠它对应请求和结果"content": result, })# 5. 带着工具结果进入下一轮,直到模型不再调工具returnf"跑了 {MAX_ROUNDS} 轮还没结束,为防失控已强制停止"流程用人话过一遍:
每一轮,把完整的对话历史发给模型(还记得上一篇说的吗,LLM 是无状态服务,session 靠客户端自己带)
回复里的 message.tool_calls 非空,说明它还想调工具;为空,说明它认为干完了
想调工具?好,我的代码真正去执行,把结果标注上 tool_call_id 发回去——模型永远不会碰你的数据库,碰数据库的是你自己的代码
循环往复,直到模型给出最终答案
第三部分:五个工程细节,也是框架帮你干的事
写这 196 行的过程中,有五个地方是新手几乎必踩的坑。有意思的是,它们恰好就是"框架到底帮你做了什么"这个问题的答案。
① 防失控。 循环必须有轮数上限(我们设了 MAX_ROUNDS = 10)。模型偶尔会陷入"查一下→不满意→再查一下"的死循环,没有这道保险,你的 token 就是它的自助餐。
② 错误要还给模型,而不是抛出去。 模型写错 SQL 太正常了。正确姿势是 catch 住,把报错文本原样作为工具结果发回去——它看到报错会自己改 SQL 重试。这是 Agent 最神奇的特性之一:它自带重试和纠错,前提是你把错误信息喂给它。
③ tool_call_id 必须回填。 模型一次可能并行调用多个工具——文章开头那次真实运行里,第 2 轮就一口气发了两条 SQL——全靠这个 id 把请求和结果对上。漏了它,API 直接报错。
④ 每个 tool_call 都要有对应的结果消息。 并行调了两个工具,就要回两条 role: "tool" 消息,一条都不能漏,漏了 API 直接拒收。别只回你觉得重要的那条。
⑤ assistant 回复要原样入历史。 包括 tool_calls 字段在内,一个都不能删。历史被篡改过的对话,模型的行为会变得很奇怪(API 甚至会直接拒绝)。
现在回头看框架:LangGraph 的"状态图"、CrewAI 的"角色协作",核心都是把上面这些事(外加持久化、可观测、人工审批……)做成了通用组件。先手写一遍,你再看框架文档就不是背概念,而是"哦,这个我写过"。
留给你的三个实验
代码在手,建议动手改改,比看十篇文章有用:
加一个工具:比如 send_report(把结果"发送"到控制台假装发邮件),观察模型什么时候会主动用它
故意把 description 写烂:把 query_db 的描述改成一个字"查",看看模型的表现退化成什么样——你会直观理解为什么说 description 是灵魂
打印每轮的 token 用量:response.usage 里都有,算算回答一个问题花多少钱,体会一下为什么"成本控制"是生产化的大头
写在最后
196 行,一个能真正干活的 Agent。它当然简陋——没有流式输出、没有上下文压缩、没有持久化、没有权限审批。但骨架是完整的,而且你现在拥有这个骨架,而不是隔着框架猜它。
下一篇预告:骨架有了,该看看市面上的"精装修"了——《2026 框架地图:LangGraph / CrewAI / OpenAI Agents SDK 到底怎么选》,正好 LangGraph 最近刚发布 1.0 正式版,是个好时机。
觉得有用的话,点个关注,咱们每周见。