次元图库
36.81M · 2026-03-09
这两年大家一提 Agent,脑子里很容易浮现出一个“会思考、会规划、会调用工具、还会自我修复”的高级智能体。听起来很玄,但如果把那些花哨概念都剥掉,一个能跑起来的极简 Agent,其实没有那么复杂。
说到底,它就两件事:
while 循环。大模型本身既没有状态,也没有手脚。它只负责在当前上下文里做判断:这轮该说话,还是该调用某个工具;如果调用工具,工具名是什么,参数应该怎么填。真正读文件、执行命令、写入内容、保存记忆、控制权限的,都是你写的程序。
所以 Agent 的核心从来不是“让模型像人一样思考”,而是:如何把合适的信息在合适的时机交给模型,再把模型的决策稳稳地落到执行环境里。
这篇文章就结合我手头这套玩具代码,从 0 到 1 拆一下:如何把一个看起来很复杂的 Agent,拆成几个可以逐步实现的小能力。
一个最小 Agent,至少要有下面这条闭环:
用户提出任务
-> 模型判断是否需要工具
-> 程序执行工具
-> 把执行结果回填给模型
-> 模型继续决策
-> 直到模型不再调用工具,输出最终答案
这个流程也可以写成更工程化一点的五步:
tool_call。tool_call,在本地执行真实工具。tool 消息带回模型,进入下一轮。只要这条链打通,一个最简 Agent 就已经成立了。
while 循环?因为 Agent 和普通聊天机器人的根本区别,不在于“更聪明”,而在于“能继续行动”。
普通聊天模型通常是一次请求、一次回复,停在那里。Agent 则是在程序外面再包一层循环,让它可以不断经历:
Think -> Act -> Observe -> Think
这在代码里其实非常朴素。simple-agent.py 里就是典型的双层循环:
while True: # 外层:处理用户输入
user_input = input("n 你: ")
if user_input.lower() in ["exit", "quit"]:
break
messages.append({"role": "user", "content": user_input})
while True: # 内层:Think -> Act -> Observe
response = client.chat.completions.create(
model="doubao-seed-2.0-code",
messages=messages,
tools=TOOLS_SCHEMA,
)
message = response.choices[0].message
messages.append(message)
if message.tool_calls:
for tool_call in message.tool_calls:
func_name = tool_call.function.name
func_args = json.loads(tool_call.function.arguments)
result = AVAILABLE_FUNCTIONS[func_name](**func_args)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"name": func_name,
"content": result,
})
else:
print(f"n 回复:n{message.content}")
break
这段代码不高级,但已经足够说明问题:Agent 的运行感,本质上就是程序不断把“观察结果”重新塞回上下文,驱动模型继续往前走。
这一版要解决的问题很单纯:先让模型不只是会回答,而是真的能动手做事。
最小化实现时,我只给 Agent 4 个基础工具:
execute_bash:执行 shell 命令。read_file:读取文件内容,并自动加行号。write_file:创建或覆盖文件。list_files:列出项目里的文件结构。这四个工具已经足够覆盖一个非常朴素的“代码助手”场景:先看目录,再读文件,需要时写文件,最后跑命令验证。
例如 execute_bash 和 read_file 的实现都很直接:
def execute_bash(command: str) -> str:
"""执行 Bash 命令"""
result = subprocess.run(
command, shell=True, capture_output=True, text=True, timeout=300
)
output = result.stdout + result.stderr
if len(output) > 2000:
output = output[:2000] + "n... (输出过长已截断)"
return output.strip() or "(无输出)"
def read_file(path: str) -> str:
"""读取文件内容"""
if not os.path.exists(path):
return f"文件不存在: {path}"
with open(path, "r", encoding="utf-8") as f:
content = f.read()
lines = content.splitlines()
numbered_lines = [f"{i + 1:4d} | {line}" for i, line in enumerate(lines)]
return "n".join(numbered_lines)
这两个细节其实都很关键。
因为工具输出最终都会进入模型上下文。如果不截断,pytest、npm install 或者 tree 这种命令一跑,很容易直接把上下文打爆。极简 Agent 的第一原则不是“信息越多越好”,而是“信息足够且可控”。
因为模型处理代码时,天然更适合引用“位置”而不是纯文本。加了行号之后,模型可以更稳定地表达:
这属于很小的工程处理,但对可用性帮助很大。
因为你不能只在提示词里写一句“你可以读取文件”。那样对模型来说太模糊了。
真实可用的方式,是把每个工具都声明成结构化接口。simple-agent.py 里每个工具都配了 JSON Schema,明确告诉模型:
例如:
{
"type": "function",
"function": {
"name": "write_file",
"description": "创建或覆盖文件内容。",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "文件路径"},
"content": {"type": "string", "description": "要写入的完整内容"},
},
"required": ["path", "content"],
},
},
}
这一步的价值,在于把“自然语言能力”变成“半结构化决策”。模型不再只是“理解你想写文件”,而是必须产出一个符合约束的参数对象。
所以很多人说 Agent 是“LLM + Tools”,我更愿意写成:
Agent = LLM + Tool Schema + Runtime Loop
少掉任何一个,都不稳定。
这一版要解决的问题是:任务做完之后,怎么让 Agent 不停在原地,而是自然进入下一轮。
最简版本虽然能工作,但它更像“一次性工具”:用户提一个需求,模型做完,流程结束。
如果你希望它像一个真正的助手,而不是一次性脚本,一个很直接的方向就是:让它在完成当前任务后,能够自然地进入下一轮交互。
这里最简单的做法,不是重写整个架构,而是补一个专门负责“续轮”的工具。simple-agent-v2.py 里我加了一个 continue_interaction:
def continue_interaction() -> str:
"""询问用户是否继续交互"""
print("n" + "=" * 50)
user_choice = input("继续? [按 N 退出,或输入新的指令] ").strip()
print("=" * 50)
if user_choice.lower() in ["n", ""]:
return "__EXIT__"
return user_choice
这个设计看起来有点“绕”:明明程序自己就能 input(),为什么还要把“继续吗”也做成一个工具?
因为这样做之后,是否进入下一轮交互,不再只是主程序的控制逻辑,而是变成 Agent 工作流的一部分。
也就是说,系统提示词里可以明确要求模型:
这样一来,如果你想把一次执行改造成“连续会话”,路径就很清楚了:
完成当前任务
-> 给出答案
-> 主动确认是否继续
-> 若继续,携带旧上下文进入下一轮
在运行时,程序只要识别特殊退出信号即可:
if result == "__EXIT__":
print("n再见!")
return
从工程角度看,这一步不是必须的,但它是一个非常轻量、非常实用的升级。只加一个工具和一条提示词规则,就能把 Agent 从“一次性执行器”变成“可持续交互的会话体”。
这一版要解决的问题是:当能力越来越多时,怎么扩展 Agent,而不把主提示词写成一团巨大的说明书。
Skill 这两年很火,原因也不复杂:它提供了一种很自然的方式,让 Agent 在不膨胀主提示词的前提下,按需获得额外能力。
如果你也想给自己的 Agent 加一个“Skill 系统”,最简单的做法其实不是上来就做复杂的插件框架,而是先把 Skill 当成一种按需加载的外部说明书。
很多人第一反应是:把所有说明文档、所有规则、所有用法,统统塞进系统提示词。
这在初期看似简单,后期几乎一定崩。
原因很直接:
如果你想用一个很轻的方法实现 Skills,simple-agent-skills.py 的思路其实就够用了,本质上就是渐进式加载(Progressive Disclosure) 。
技能目录大概长这样:
skills/
├── skill1/
│ ├── SKILL.md
│ ├── scripts/
│ ├── references/
│ └── assets/
└── skill2/
└── SKILL.md
其中 SKILL.md 既是说明文档,也是技能入口。实现上不需要什么复杂协议,先约定每个 Skill 都有一个 SKILL.md,并在文件头放上最基本的 frontmatter,比如 name 和 description。
然后 skills_loader.py 在启动时做两件事:
skills/ 目录。SKILL.md 的摘要信息。def parse_skill_frontmatter(content: str) -> Optional[dict]:
lines = content.split('n')
if not lines[0].strip() == '---':
return None
end_idx = -1
for i, line in enumerate(lines[1:], 1):
if line.strip() == '---':
end_idx = i
break
if end_idx == -1:
return None
yaml_lines = lines[1:end_idx]
body_content = 'n'.join(lines[end_idx + 1:])
metadata = {}
for line in yaml_lines:
line = line.strip()
if ':' in line:
key, value = line.split(':', 1)
metadata[key.strip()] = value.strip().strip('"').strip("'")
if 'name' not in metadata or 'description' not in metadata:
return None
return {
'metadata': metadata,
'body': body_content,
'full': content
}
这段代码并不复杂,但已经能把一个“极简 Skill 系统”跑起来了。关键点在于:启动时只加载“摘要”,不加载“全文”。
系统提示词里给模型看的,不是每个 Skill 的完整正文,而是一个技能目录:
def get_skills_prompt() -> str:
lines = ["可用 Skills:", ""]
for skill_name, skill_data in LOADED_SKILLS.items():
metadata = skill_data['metadata']
lines.append(f" - /{metadata['name']}: {metadata['description']}")
lines.append(f" SKILL 位置: {skill_data['skill_md_path']}")
return "n".join(lines)
也就是说,模型先知道“有什么技能”,如果任务真的需要,再通过 read_file 读取某个 SKILL.md 的完整内容,或者继续去读它的 scripts、references、assets。
这是一种非常典型的 Agent 上下文工程模式:
如果你只是想快速给 Agent 加一个 Skill 概念,这个方案已经有几个明显优点:
换句话说,Skill 不一定非得是一个复杂框架。最小实现完全可以只是: “给模型一份技能索引,需要时自己去读说明书。”
这一版要解决的问题是:如果希望 Agent 保留一些长期信息,最小可以怎么落地。
很多人在做 Agent 时,都会想到 Memory。这个方向当然有价值,但一上来没必要把它想得太重。
如果你的目标只是做一个极简可用的记忆系统,最简单的办法不是上向量库,而是先落一个本地文件,把值得保留的信息存起来,并提供几个最基础的检索方法。
simple-agent-skills-memory.py 这一步就做得很克制:不搞向量数据库,不搞 embedding 检索,先用一个 memory.md 文件把记忆保存下来。
记忆写入函数是这样的:
def save_memory(content: str, category: Optional[str] = None) -> str:
init_memory()
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(MEMORY_FILE, "a", encoding="utf-8") as f:
f.write(f"---n")
f.write(f"**时间:** {timestamp}n")
if category:
f.write(f"**分类:** {category}n")
f.write(f"**内容:**n{content}nn")
return f"已保存记忆到 {MEMORY_FILE}"
这个方案做的事情非常朴素:
然后再配上几个最基本的检索工具:
read_memory(limit=10):读取最近 N 条。search_memory(keyword):按关键词搜索。search_memory_by_category(category):按分类检索。例如关键词搜索:
class MemorySearcher:
@staticmethod
def search_by_keyword(keyword: str) -> str:
init_memory()
results = []
with open(MEMORY_FILE, "r", encoding="utf-8") as f:
content = f.read()
lines = content.split("n")
current_entry = []
in_entry = False
for line in lines:
if line.startswith("---"):
if in_entry and current_entry:
entry_text = "n".join(current_entry)
if keyword.lower() in entry_text.lower():
results.append(entry_text)
current_entry = []
in_entry = True
elif in_entry:
current_entry.append(line)
if results:
return f"找到 {len(results)} 条匹配的记忆:nn" + "n---n".join(results)
return f"未找到包含 '{keyword}' 的记忆"
如果你只是想实现一个“能用的极简 Memory”,这已经足够了。它当然不是最强方案,但有两个很现实的优点:
很多时候,极简 Agent 的重点不是“最先进”,而是“最先可用”。
因为它不是一上来就追求“什么都有”,而是把看起来很复杂的能力,拆成几个可以逐步叠加的小模块。
读、写、列目录、跑命令,再加上后面的技能读取和记忆管理,已经能支撑一个相当像样的代码 Agent 了。
第一步不是追求工具多,而是先确保最关键的动作都能完成。
不是急着加十几个工具,而是先保证:
这四个版本的升级路径不是“功能炫技”,而是你在继续往前做时,很自然会遇到的几个问题:
simple-agent.py:解决“怎么让模型真正动手”。simple-agent-v2.py:如果想把单次执行变成连续会话,怎么做。simple-agent-skills.py:如果想按需扩展能力、又不想把提示词塞爆,怎么做。simple-agent-skills-memory.py:如果想让 Agent 记住一些长期信息,怎么做。这种递进方式更容易让读者理解:Agent 的很多“高级能力”,其实都可以从一个很小的实现开始。
很多人实现 Agent 时,注意力都放在模型选型上:是 GPT、Claude、还是别的模型;是大参数、还是长上下文。模型当然重要,但真落到工程上,决定 Agent 体验的,往往不是参数规模,而是上下文管理。
这也是为什么前面的每一步扩展,看起来都像是在补“小功能”,本质上却都在处理上下文问题。比如这套代码里,已经能看到几个很典型的策略:
过长的 shell 输出直接截断,防止上下文膨胀。
read_file 自动加行号,把“原始文本”变成“可引用观察”。
Skill 只暴露索引,正文和资源由模型按需读取。
把易丢失的信息沉淀到 memory.md,而不是指望模型“记住”。
如果你在这套极简实现上继续往前推,下一层通常会补这些能力:
也就是说,前面那些看起来复杂的特性,很多本质上都只是上下文工程的不同形式。
虽然这套实现已经能用,但如果你真要拿它继续往前做,有几个坑是绕不过去的。
execute_bash 很强,也很危险只要给了模型任意 shell 能力,它理论上就能执行任何命令。玩具项目无所谓,真实环境里至少要考虑:
否则 Agent 不是在帮你干活,是在帮你制造事故。
write_file 是覆盖写,不是增量编辑这对最简原型足够,但对复杂项目不够友好。真实开发里更常见的需求其实是:
如果一直全量重写,文件越大,出错概率越高。
基于 Markdown 和关键词的记忆系统非常直观,但在记忆量变大后,召回效果会逐步下降。这时才需要考虑 embedding、向量库或者分层记忆。
但重点是顺序别反了:先有可解释的记忆,再追求高级检索。
如果你也想自己写一个 Agent,我会建议按下面这个顺序来,不要一上来就追求“全能智能体”。
换句话说:
先让它能跑
-> 再让它能连续跑
-> 再让它跑得久
-> 最后再让它跑得稳
这个顺序的好处是,读者能很直观地看到:每一个看起来复杂的功能,背后都可以有一个非常小、非常具体的起点。
比起一开始就堆满 Planner、Router、Reflection、Multi-Agent 协同之类的大词,这种实现路径更容易落地。
这套示例代码目前大致是这样分层的:
agent-learning/
├── simple-agent.py
├── simple-agent-v2.py
├── simple-agent-skills.py
├── simple-agent-skills-memory.py
├── skills_loader.py
├── memory_tools.py
├── memory.md
└── skills/
└── web-search/
└── SKILL.md
各自职责也比较清楚:
simple-agent.py:最小可运行 Agent,只有基础工具和双层循环。simple-agent-v2.py:加入 continue_interaction,把会话真正串起来。simple-agent-skills.py:引入 Skill 索引和按需加载。simple-agent-skills-memory.py:把长期记忆工具并进 Agent。skills_loader.py:负责扫描技能目录、解析 SKILL.md、生成技能摘要。memory_tools.py:负责记忆初始化、写入、读取和检索。这个结构的好处是:每一步升级都不是推倒重来,而是在前一个版本上补一层能力。所以整篇文章也更适合按“先做最小版,再逐步叠加”的方式来理解。
如果把 Agent 神秘化,你会觉得它像一个“会自主思考的数字生命”;但如果回到代码层面看,它其实就是一个很朴素、而且可以逐步搭起来的工程系统:
所以,实现一个极简 Agent 的关键,不是先追求“像人”,而是先把能力拆小,然后一步一步加上去。
先想清楚这些问题:
把这些问题答清楚,一个能真正干活的 Agent,基本也就搭出来了。
说得再直白一点:
Agent 不神秘,它只是一个被设计得足够好的循环系统。