从零开始设计一个 Agent(上篇)
AI-FDE 转型之路 · AI 工程 09-Agent设计与实现
本文是「从零开始设计一个 Agent」三篇中的上篇,覆盖设计主线的前七节。 本篇路线:Agent 是什么 → 全貌 → 最小闭环 → 停止线 → 观察 → 文件四件套。 中篇从 shell 开始,下篇讲扩展、记账与验收。
一、Agent 是让模型能动手的一段程序
模型自己能做的事只有一件:给它一段文字,它接下一段文字。
它看不见你服务器上的文件,按不了你系统里的按钮,也查不了你的数据库。你问它“这笔授信的材料齐了没有”,它只能根据你贴给它的内容猜,而且猜得还挺像那么回事。
Agent 就是在模型外面套的一层程序,给它三样东西:
| 给的 | 是什么 | 缺了会怎样 |
|---|---|---|
| 手 | 一组工具:读文件、查接口、跑命令 | 只能说,不能做 |
| 来回 | 一个循环:做完一步看结果,再决定下一步 | 只能做一步,错了也只能错到底 |
| 收手 | 退出条件:什么时候算完,什么时候必须停 | 停不下来 |
一句话:Agent 是让模型从“说”变成“做”的那段代码。
它能干什么,具体一点:
客户经理问“这笔授信的材料还缺什么”,它去调三个接口,把缺的几项列出来——这个需求只有接口,没有现成页面
一批制度文件要按新目录重新归档,它自己读、判断该放哪、移动、写一条变更记录
一段代码改完,它自己跑测试,失败了自己看日志再改,直到通过,或者撞上限停下来
也要说清它干不了什么,这三条比能干更要紧:
它不会更懂业务。 业务规则在你的制度文件、接口约定、审批口径里,不在模型里。Agent 只是把规则用起来,不是替你想规则。
它不保证正确,只保证可查。 它能给的确定性是“每一步都留了痕”,不是“结论一定对”。
它不该碰要求百分之百确定的动作。 转账、扣款、批量删除这类,写成固定的代码分支,不要交给它现场判断。
二、Agent 与智能体应用的区别
现在市面上说的“智能体”,多数指的是智能体应用:在扣子、Dify、百炼这类平台上,选个模型、配个知识库、串几条流程、挂一个对话框,点发布,就是一个应用。业务人员培训两天就能搭出来一个。
这一篇要设计的 Agent 不是这个,它在下面一层。
打个比方:智能体应用是整车,Agent 是发动机。
低代码平台卖的是整车——能开,还替你装了限速。你要做的业务,八成用整车就够了。但总会撞上三种情况:要接一个平台接不进的内网系统、要在危险动作前面加自己的拦截、要跟客户解释清楚“它每一步到底干了什么”。这三种,整车都不给你改。
对照着看:
| 维度 | 智能体应用 | 这一篇设计的 Agent |
|---|---|---|
| 是什么 | 一个打开就能用的产品 | 一段能被程序调用的运行时 |
| 谁在用 | 最终用户,人 | 另一个程序,或一个更大的系统 |
| 边界谁定 | 平台定好,你在框里编排 | 你定,写在哪就管到哪 |
| 长什么样 | 有界面、有入口、有发布按钮 | 一个函数 run_agent_turn(messages) |
| 主要风险 | 答不准、流程断、体验差 | 改错东西、停不下来、事后查不出原因 |
| 出事怎么查 | 翻对话记录 | 翻每一步的工具调用日志 |
| 谁来做 | 业务人员也能搭 | 要能读代码 |
所以这一篇设计的是什么,说具体:
我们设计的是那个循环,以及循环外面的三层刹车。 循环一句话就写完了——调模型,模型说要调工具,执行工具,把结果塞回历史,再调模型,直到它不再要工具,或者撞上上限。
这个循环几十行就能写出来。难的是让它能在生产里待住,后面十几节,全在补这个循环的漏洞。
三、先看全貌:一个循环,三层刹车
在拆代码之前,先看现在这个版本的整体结构。
它不是一个“很聪明的 Agent”,而是一个被围起来的循环。从外到内,分六层:
| 层 | 解决的问题 | 当前实现 |
|---|---|---|
| 循环层 | 一轮怎么跑、什么时候停 | run_agent_turn、max_steps=8 |
| 工具层 | 能做什么、不能做什么 | 文件四件套、run_shell、grep、find_files、插件、MCP、Skills |
| 边界层 | 改坏了怎么办 | _safe_path、SAFE_CMDS、人工确认、超时、子 Agent profile |
| 上下文层 | 历史太长怎么办 | trim_messages、[:3000]、max_results |
| 状态层 | 运行时怎么调 | messages、state、斜杠命令、模型配置 |
| 验收层 | 怎么知道它真干完了 | facts / check_before_answer、jsonl 日志、AGENT.md |
六层里,真正能挡住事故的是三层刹车:
| 刹车 | 装在哪 | 具体是什么 |
|---|---|---|
| 工具层刹车 | 模型看不见的地方 | 路径边界、命令白名单、脚本审计、人工确认、超时 |
| 状态层刹车 | 循环和上下文里 | 步数上限、按轮裁剪、单条输出截断、子 Agent 权限 |
| 验收层刹车 | 最终回答之前 | 程序记账、客观检查、逐次日志、长期记忆 |
写在系统提示词里的“不要删除文件”是请求,不是刹车。 模型可以不听,而且它经常不听——不是它坏,是它每次都在重新理解这句话。真正拦得住的东西,必须写在模型看不见、也改不了的地方。
四、最小闭环:一个 run_agent_turn
先把循环跑通。当前版本的核心函数叫 run_agent_turn,去掉日志、记账、子 Agent 分支之后,骨架是这样:
def run_agent_turn(client, model, messages, state, max_steps=8, verbose=True):
for step in range(1, max_steps + 1):
stream = client.chat.completions.create(
model=model, messages=messages, tools=TOOLS,
tool_choice="auto", stream=True,
)
content = ""
acc = {}
for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta.content:
print(delta.content, end="", flush=True)
content += delta.content
for tc in delta.tool_calls or []:
i = tc.index
acc.setdefault(i, {"id": "", "name": "", "arguments": ""})
if tc.id:
acc[i]["id"] = tc.id
if tc.function:
if tc.function.name:
acc[i]["name"] = tc.function.name
if tc.function.arguments:
acc[i]["arguments"] += tc.function.arguments
# 没有工具调用 → 它认为说完了
if not acc:
messages.append({"role": "assistant", "content": content})
return content
tcs = [acc[i] for i in sorted(acc)]
messages.append({
"role": "assistant", "content": content or None,
"tool_calls": [
{"id": t["id"], "type": "function",
"function": {"name": t["name"], "arguments": t["arguments"]}}
for t in tcs
],
})
for t in tcs:
result = execute_tool_call(t)
messages.append({
"role": "tool", "tool_call_id": t["id"], "content": str(result),
})
return "(达到最大步数,未完成任务)"
这段代码里有四个不能省的东西:
第一,assistant 的 tool_calls 必须原样写回历史。 不能只写 content。下一轮模型要靠这个知道自己刚才要调什么。
第二,每个 tool_call 必须有一条对应的 tool 消息。tool_call_id 要对上。少一条、错一个 id,接口直接 400。这个约束在后面裁剪历史时还会再咬一次。
第三,退出条件是“不再要工具”。 模型不再返回 tool_calls,就认为它说完了。这个判断很简单,但它把“什么时候停”交给了模型——所以外面必须有 max_steps。
第四,max_steps 是保险丝,不是刹车。 它只保证电路烧了之后会断,不保证不烧。八步挡得住死循环,挡不住“在错误方向上有条不紊地跑八步”。
为什么必须有循环
如果只调两次模型:第一次让它决定调什么工具,执行完,第二次直接要答案——第二次请求里没有 tools,它就没法根据第一次的结果再要工具。
任务只要依赖上一步的结果,就会断在那里。循环不是重复,是再决定一次的机会。
五、停止线:别只装一个步数上限
“会停”比“会做”难。
当前版本装了几个刹车,但没装全。先把现状摊开:
| 刹车 | 当前实现 | 状态 |
|---|---|---|
| 步数 | max_steps=8;子 Agent SUB_AGENT_MAX_STEPS=6 |
有 |
| 工具超时 | run_shell 默认 30s;MCP 调用 60s |
有 |
| 墙钟 | 没有整轮 deadline | 债 |
| 花费 | 没有 Token / 金额预算 | 债 |
| 重复 | 没有同工具、同参数的重复检测 | 债 |
步数上限是唯一一个“不管模型怎么想,跑满就停”的硬条件。它很重要,但只解决“转多久”,不解决“往哪转”。
一个八步的循环,可以很有条理地做八件错事。现场更常见的事故不是死循环,是方向错了还跑得很顺。
所以完整的停止线至少要有四道:
步数:一轮最多几步;子 Agent 单独设
墙钟:一轮最多多少秒,超了就返回,不管模型还想不想继续
花费:一轮最多多少 Token 或多少钱,超了就停
重复:同一个工具、同一组参数连续出现 N 次,直接判死
当前版本只有第一道和工具级超时。后三道是明确的债,不能因为文章写了就当成已经有了。
一个最小的墙钟补法,不需要改模型:
deadline = time.time() + 120
for step in range(1, max_steps + 1):
if time.time() > deadline:
return "(超过本轮时限,已停止)"
...
重复检测也一样,在工具执行前记一张 (name, arguments) 的表,连续命中两次就拒绝,第三次就结束本轮。
六、观察:先让自己看得见
Agent 出问题的时候,百分之八十的时间是花在“不知道它刚才干了什么”上。
当前版本有三层观察:
第一层是流式。stream=True,模型每吐一个字就打印。它不是为了看着像打字机,是为了区分“网络慢”“模型在思考”“工具卡住”这三种完全不同的情况。
第二层是 verbose。 每次工具调用打印一行:
[step 1] 调用工具: ['list_dir', 'read_file']
[step 1] list_dir → [FILE] add.py (67 bytes) ...
[step 1] read_file → print("Hello China")
[step 2] 调用工具: ['edit_file', 'write_file']
[step 2] edit_file → 已修改 sandbox/hello.py
[step 2] write_file → 已写入 sandbox/note.md(5 字符)
这四行比“已完成”三个字有用得多。它告诉你:它先看了目录,再读了文件,然后同时改了文件、写了新文件。
第三层是 /log 和 /stats。/log 把 jsonl 日志按时间倒着列出来,/stats 看本次会话的 Token、耗时、工具调用次数。日志那一节会展开。
单步:一个没有进主线的调试开关
早期我做过一个单步模式:每跑完一步停下来,等人敲回车,可以继续、中止、关闭单步。
它有用,但没进主线。原因是它只影响调试,不影响能力;而把它塞进子 Agent、并行、日志这些循环里,会让主流程复杂很多。
当前版本用 verbose + /log 代替它。如果你确实需要一步一步看,可以把 _step_pause 从 v2.3 那个旁支搬过来——它不复杂,核心就是一次 input(),加一个中止时补齐 tool 消息的处理。
七、文件四件套:read_file / write_file / edit_file / list_dir
Agent 一旦能碰文件,风险就从“说错话”变成“改坏了”。
所以这一节的第一件事不是加工具,是先画圈,圈住唐长老:
WORKSPACE = os.path.abspath(os.getcwd())
def _safe_path(path: str) -> str:
"""把相对路径拼到工作区,并防止越界"""
full = os.path.abspath(os.path.join(WORKSPACE, path))
if not full.startswith(WORKSPACE + os.sep) and full != WORKSPACE:
raise ValueError(f"路径越界,只能在 {WORKSPACE} 内操作: {path}")
return full
../../ 走不出去,/etc/passwd 也走不出去。四个文件工具全部先过这一道。
给工具之前先给它画边界,顺序不能反。 先给工具、后加限制,中间那段窗口就是事故窗口。
四个工具,各管一件事
| 工具 | 参数 | 作用 | 什么时候用 | 主要风险 |
|---|---|---|---|---|
read_file |
path |
读 UTF-8 文件全文 | 知道路径,先看内容 | 大文件会撑爆上下文 |
write_file |
path、content |
新建或整体覆盖 | 新文件、整段重写 | 会静默覆盖原文件 |
edit_file |
path、old_text、new_text |
精确替换一处 | 改已有文件的一小段 | 空白字符不匹配、匹配到多处 |
list_dir |
path="." |
列目录,带类型和大小 | 先看目录结构 | .venv 这类目录会刷屏 |
四个工具的实现都不长,但每个都藏着一个设计决定:
def read_file(path):
try:
with open(_safe_path(path), "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
return f"读取失败: {e}"
def write_file(path, content):
try:
full = _safe_path(path)
parent = os.path.dirname(full)
if parent:
os.makedirs(parent, exist_ok=True)
with open(full, "w", encoding="utf-8") as f:
f.write(content)
return f"已写入 {path}({len(content)} 字符)"
except Exception as e:
return f"写入失败: {e}"
def edit_file(path, old_text, new_text):
try:
full = _safe_path(path)
with open(full, "r", encoding="utf-8") as f:
content = f.read()
if old_text not in content:
return f"未找到要替换的内容: {old_text[:50]}..."
if content.count(old_text) > 1:
return f"找到 {content.count(old_text)} 处匹配,请提供更精确的 old_text"
with open(full, "w", encoding="utf-8") as f:
f.write(content.replace(old_text, new_text))
return f"已修改 {path}"
except Exception as e:
return f"编辑失败: {e}"
def list_dir(path="."):
try:
full = _safe_path(path)
if not os.path.isdir(full):
return f"不是目录: {path}"
lines = []
for item in sorted(os.listdir(full)):
fp = os.path.join(full, item)
if os.path.isdir(fp):
lines.append(f"[DIR] {item}/")
else:
lines.append(f"[FILE] {item} ({os.path.getsize(fp)} bytes)")
return "\n".join(lines) if lines else "(空目录)"
except Exception as e:
return f"列目录失败: {e}"
每个工具的关键点
read_file 只负责读,不负责判断该不该读。 它把整个文件原样返回。文件小的时候很好用,文件大的时候会直接把上下文吃掉。当前版本还没有做截断,这是债;生产里应该加 max_bytes 或按行截断,并在返回里明确写“已截断”。
write_file 是覆盖,不是追加。 它会把原文件整个替换掉,只在“新建文件”或“整段重写”时用。改已有文件的一小段,必须用 edit_file。这个区分不是洁癖——write_file 一旦被模型用来“改一行”,它很可能把整个文件写成它记得的样子,原来的内容就没了。
edit_file 的核心是“唯一匹配”。 找不到 old_text,拒绝;找到多处,也拒绝。它宁可让模型重试,也不猜“你想改哪一处”。这是四件套里最重要的一个刹车。
list_dir 只给结构,不给内容。 它返回 [DIR] / [FILE] 和文件大小,让模型先知道“有什么”,再决定“读哪个”。当前版本没有跳过 .venv、__pycache__,目录一大就会刷屏;这是下一个要补的洞。
什么时候用哪个
不知道文件在哪:先
grep或find_files知道路径,先看内容:
read_file新建文件、整段重写:
write_file改已有文件的一小段:
edit_file先看目录结构:
list_dir
这五个动作覆盖了“找、看、建、改、列”,也正好是文件操作的最小闭环。工具不是越多越好,先把这五个的边界写清楚。
真实运行:一次把四件套走完
2026-10-28 复跑当前版本,让它按顺序做四件事:列目录、读文件、改一行、新建文件。
{"ts": 1793198120.316, "kind": "tool_call", "name": "list_dir", "args_preview": "{\"path\": \"sandbox\"}", "result_preview": "[FILE] add.py (67 bytes)\n[FILE] hello.log (12 bytes)\n[FILE] hello.py (126 bytes)", "elapsed": 0.0}
{"ts": 1793198120.316, "kind": "tool_call", "name": "read_file", "args_preview": "{\"path\": \"sandbox/hello.py\"}", "result_preview": "#!/usr/bin/env python3\n# -*- coding: utf-8 -*-\n\n\ndef main():\n print(\"Hello China\")\n\n\nif __name__ == \"__main__\":\n main()\n", "elapsed": 0.0}
{"ts": 1793198121.78, "kind": "tool_call", "name": "edit_file", "args_preview": "{\"path\": \"sandbox/hello.py\", \"old_text\": \" print(\\\"Hello China\\\")\", \"new_text\": \" print(\\\"Hello Agent\\\")\"}", "result_preview": "已修改 sandbox/hello.py", "elapsed": 0.0}
{"ts": 1793198121.781, "kind": "tool_call", "name": "write_file", "args_preview": "{\"path\": \"sandbox/note.md\", \"content\": \"done\\n\"}", "result_preview": "已写入 sandbox/note.md(5 字符)", "elapsed": 0.0}
还原成终端视图:
> 先 list_dir sandbox,再 read_file sandbox/hello.py,然后 edit_file 把 Hello China 改成 Hello Agent,最后 write_file 新建 sandbox/note.md
[step 1] list_dir → [FILE] add.py (67 bytes) / hello.log (12 bytes) / hello.py (126 bytes)
[step 1] read_file → print("Hello China")
[step 2] edit_file → 已修改 sandbox/hello.py
[step 2] write_file → 已写入 sandbox/note.md(5 字符)
[step 3] read_file → print("Hello Agent")
[step 4] read_file → done
四个动作,四次工具调用,日志里每一步都能对上。改完它还自己读回确认了一遍——这不是提示词让它做的,是 read_file 这个工具让“确认”变得便宜。
日志原文在 logs/samples/files_rerun_20261028_223517.jsonl。
这一节留下的债
read_file没有大小上限,遇到大文件会吃上下文list_dir没有跳过.venv/__pycache__,目录一大就刷屏write_file的覆盖风险靠提示词约束,没有程序层的“改前备份”edit_file对空白字符敏感,模型改缩进时容易匹配失败
这四条都不是“再加一个工具”能解决的,得在实现里补参数和边界。
上篇到这里。 中篇从 shell 开始:白名单、脚本审计、人工确认、超时;然后是检索与上下文、运行时控制、子 Agent 权限。
《AI-FDE 转型之路》:写给不写代码的 IT 实施 / 售前 / 运维,把已有的交付能力「AI 化」。全系列 55 篇,六大模块 53 项技能。
想先看看自己缺什么:AI-FDE 技能自评地图,53 项打分,出一张雷达图。AI-FDE 转型之路 · 开篇
