找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4034

积分

0

好友

564

主题
发表于 1 小时前 | 查看: 2| 回复: 0

这两年,大家一提起 Agent,很容易想到一个“会思考、会规划、能调用工具、还能自我修复”的高级智能体。听起来很玄乎,但如果我们剥掉那些花哨的概念,一个能实际跑起来的极简 Agent,其实没想象中那么复杂。

说到底,它就干两件事:

  • 一个持续运行的 while 循环。
  • 一套精心设计的上下文工程。

大模型本身既没有状态,也没有手脚。它只负责在当前提供的上下文里做判断:这一轮应该直接回复,还是调用某个工具;如果要调用工具,工具名是什么,参数该怎么填。真正去读文件、执行命令、写入内容、保存记忆、控制权限的,都是你写的程序。

所以,Agent 的核心从来不是“让模型像人一样思考”,而是:如何把合适的信息在合适的时机交给模型,再把模型的决策稳稳地落到执行环境里。

这篇文章就结合我手上的这套玩具代码,从 0 到 1 拆解一下:如何把一个看起来很复杂的 Agent,拆成几个可以逐步实现的小能力。

先讲结论:Agent 的最小闭环是什么?

一个最简化的 Agent,至少要有下面这条闭环:

用户提出任务
-> 模型判断是否需要工具
-> 程序执行工具
-> 把执行结果回填给模型
-> 模型继续决策
-> 直到模型不再调用工具,输出最终答案

这个流程也可以写成更工程化一点的五个步骤:

  1. 定义工具,并用 JSON Schema 描述参数约束。
  2. 把用户消息、系统提示词、工具清单一起发给大模型
  3. 模型返回普通文本,或者返回 tool_call
  4. 程序解析 tool_call,在本地执行真实工具。
  5. 将工具结果作为 tool 消息带回模型,进入下一轮。

只要这条链路打通,一个最基础的 Agent 就已经成立了。

为什么说本质是 while 循环?

因为 Agent 和普通聊天机器人的根本区别,不在于“更聪明”,而在于“能继续行动”。

普通聊天模型通常是一次请求、一次回复,然后对话就结束了。Agent 则是在程序外面再包一层循环,让它可以不断地经历:

Think -> Act -> Observe -> Think

这在代码里其实非常朴素。simple-agent.py 里就是一个典型的双层循环:

  • 外层循环处理用户输入,构成一个 REPL(读取-求值-打印循环)。
  • 内层循环负责模型的自主行动,只要模型还在请求工具,就一直跑下去。
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 的运行感,本质上就是程序不断把“观察结果”重新塞回上下文,驱动模型继续往前走。

示例 1:先做一个能干活的最简版本

这一版要解决的问题很单纯:先让模型不只是会回答,而是真的能动手做事。

在最小化实现时,我只给 Agent 4 个基础工具:

  • execute_bash:执行 shell 命令。
  • read_file:读取文件内容,并自动加行号。
  • write_file:创建或覆盖文件。
  • list_files:列出项目里的文件结构。

这四个工具已经足够覆盖一个非常朴素的“代码助手”场景:先看目录,再读文件,需要时写文件,最后跑命令验证。

例如 execute_bashread_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)

这两个细节其实都很关键。

1. 为什么命令输出要截断?

因为工具输出最终都会进入模型上下文。如果不加限制,像 pytestnpm install 或者 tree 这种命令一跑起来,输出很容易直接把上下文窗口打爆。极简 Agent 的第一原则不是“信息越多越好”,而是“信息足够且可控”。

2. 为什么读取文件时要加行号?

因为模型处理代码时,天然更适合引用“位置”而不是纯文本。加了行号之后,模型可以更稳定地表达:

  • 问题在第 42 行附近。
  • 需要修改第 18 到 25 行。
  • 报错和第 10 行的 import 有关。

这属于很小的工程化处理,但对可用性的提升非常直接。

工具为什么一定要用 Schema 描述?

因为你不能只在提示词里写一句“你可以读取文件”。那样对模型来说太模糊了。

真实可用的方式,是把每个工具都声明成结构化的接口。simple-agent.py 里每个工具都配了 JSON Schema,明确告诉模型:

  • 工具叫什么。
  • 适合干什么。
  • 参数有哪些。
  • 哪些参数必填。

例如,为 write_file 工具定义的 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

少掉任何一个,系统都不稳定。

示例 2:如果想让它从“一次性执行”变成“可持续交互”

这一版要解决的问题是:任务做完之后,怎么让 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 工作流本身的一部分。

也就是说,你可以在系统提示词里明确要求模型:

每次完成任务、给出最终回复后,必须调用 continue_interaction,询问用户是否继续。

这样一来,如果你想把一次执行改造成“连续会话”,路径就很清楚了:

完成当前任务
-> 给出答案
-> 主动确认是否继续
-> 若继续,携带旧上下文进入下一轮

在运行时,程序只要识别特殊退出信号即可:

if result == "__EXIT__":
    print("\n再见!")
    return

从工程角度看,这一步不是必须的,但它是一个非常轻量、非常实用的升级。只加一个工具和一条提示词规则,就能把 Agent 从“一次性执行器”变成“可持续交互的会话体”。

示例 3:如果想引入 Skills,最简单怎么做?

这一版要解决的问题是:当能力越来越多时,怎么扩展 Agent,而不把主提示词写成一团巨大的说明书。

Skill 这个概念这两年很火,原因也很直观:它提供了一种很自然的方式,让 Agent 在不膨胀主提示词的前提下,按需获得额外能力。

如果你也想给自己的 Agent 加一个“Skill 系统”,最简单的做法不是一上来就做复杂的插件框架,而是先把 Skill 当成一种 按需加载的外部说明书

很多人第一反应是:把所有说明文档、所有规则、所有用法,统统塞进系统提示词。这在初期看似简单,后期几乎一定会出问题。
原因很直接:

  • Token 成本越来越高。
  • 无关信息越来越多。
  • 模型更容易被无关的噪音干扰。
  • 每一轮都重复注入同一大坨文本,非常浪费。

如果你想用一个很轻的方法实现 Skills,simple-agent-skills.py 的思路其实就够用了,本质上就是 渐进式加载(Progressive Disclosure)

技能目录大概长这样:

skills/
├── skill1/
│   ├── SKILL.md
│   ├── scripts/
│   ├── references/
│   └── assets/
└── skill2/
    └── SKILL.md

其中 SKILL.md 既是说明文档,也是技能入口。实现上不需要什么复杂协议,可以先约定每个 Skill 都有一个 SKILL.md,并在文件头放上最基本的 frontmatter,比如 namedescription

然后 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 的完整内容,或者继续去读它的 scriptsreferencesassets

这是一种非常典型的 Agent 上下文工程模式:

  • 第一层只暴露索引。
  • 第二层按需读取正文。
  • 第三层按需展开依赖资源。

如果你只是想快速给 Agent 加一个 Skill 概念,这个方案有几个明显优点:

  • 实现简单,本质上只是目录扫描加 Markdown 约定。
  • 扩展成本低,新增 Skill 基本就是加一个目录。
  • 不会把所有 Skill 正文都塞进上下文,节省 Token。

换句话说,Skill 不一定非得是一个复杂框架。最小实现完全可以只是:“给模型一份技能索引,需要时让它自己去读说明书。”

示例 4:如果想加一个极简 Memory,可以怎么做?

这一版要解决的问题是:如果希望 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}\n\n")

    return f"已保存记忆到 {MEMORY_FILE}"

这个方案做的事情非常朴素:

  • 每条记忆都有时间戳。
  • 可以选填分类。
  • 存成纯 Markdown,方便人工查看和手动编辑。

然后再配上几个最基本的检索工具:

  • 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)} 条匹配的记忆:\n\n" + "\n---\n".join(results)
        return f"未找到包含 '{keyword}' 的记忆"

如果你只是想实现一个“能用的极简 Memory”,这已经足够了。它当然不是功能最强的方案,但有两个很现实的优点:

  • 足够简单,今天就能跑起来,不依赖外部服务。
  • 行为可解释,不是一个黑盒检索系统,你知道它是怎么找到结果的。

很多时候,构建极简 Agent 的重点不是追求“最先进”,而是保证“最先可用”。

这套极简 Agent,为什么适合用来递进式实现?

因为它不是一上来就追求“什么都有”,而是把看起来很复杂的能力,拆成几个可以逐步叠加的小模块。

1. 先用最少工具把主链路跑通

读、写、列目录、跑命令,再加上后面的技能读取和记忆管理,已经能支撑一个相当像样的代码助手 Agent 了。第一步不是追求工具数量多,而是先确保最关键的动作都能可靠地完成。

2. 再按问题逐层补能力,而不是一次堆满功能

不是急着加十几个工具,而是先保证基础能力扎实:

  • 工具描述清晰。
  • 返回结果可控。
  • 文件内容可定位(行号)。
  • 想扩展能力时再引入 Skill 系统。
  • 想保留长期信息时再补 Memory 系统。

3. 每一步演进都围绕一个具体诉求

这四个版本的升级路径不是“功能炫技”,而是你在继续往前开发时,很自然会遇到的几个问题:

  • simple-agent.py:解决“怎么让模型真正动手操作”的问题。
  • simple-agent-v2.py:如果想从单次执行变成连续会话,该怎么做。
  • simple-agent-skills.py:如果想按需扩展能力、又不想把主提示词塞爆,怎么做。
  • simple-agent-skills-memory.py:如果想让 Agent 记住一些跨会话的长期信息,怎么做。

这种递进方式更容易让人理解:Agent 的很多所谓“高级能力”,其实都可以从一个很小的、具体的实现开始。

如果继续往前做,真正需要打磨的是上下文工程

很多人实现 Agent 时,注意力都放在模型选型上:是用 GPT、Claude 还是其他模型;是选大参数还是长上下文的。模型当然重要,但真落到工程上,决定最终 Agent 体验的,往往不是参数规模,而是 上下文管理

这也是为什么前面的每一步扩展,看起来都像是在补“小功能”,本质上却都在处理上下文问题。比如这套代码里,已经能看到几个很典型的策略:

1. 结果裁剪

过长的 shell 输出直接截断,防止单次工具调用结果撑爆上下文。

2. 结构化观察

read_file 自动加行号,把“原始文本”变成“可引用、可定位的结构化观察”。

3. 渐进式加载

Skill 系统只暴露索引,具体正文和资源由模型按需读取,避免无关信息污染每轮对话。

4. 长期记忆外置

把易丢失的、需要长期保存的信息沉淀到 memory.md 文件中,而不是指望模型自己“记住”。

如果你在这套极简实现上继续向前推进,下一层通常会补充这些更工程化的能力:

  • 上下文压缩:把旧的、不那么重要的消息总结成结构化摘要。
  • 权限分级:区分只读操作(如 read_file)和高风险操作(如 execute_bash, write_file)。
  • 人工确认:对于高风险动作(如删除文件),必须经过用户确认。
  • 错误恢复:工具执行失败后,把具体的错误信息回传给模型,让它有机会重试或调整策略。
  • 会话持久化:将会话状态保存下来,中断之后还能接着跑。

也就是说,前面那些听起来复杂的特性,很多本质上都只是 上下文工程 在不同维度的具体体现。

极简不等于粗糙,最少也要注意这些问题

虽然这套实现已经能用,但如果你真要拿它继续往前做,有几个问题是绕不过去的,需要在后续迭代中重点关注。

1. execute_bash 很强,也很危险

只要给了模型任意执行 shell 命令的能力,它理论上就能执行任何操作。在玩具项目里无所谓,但在接近真实环境时至少要考虑:

  • 命令白名单机制。
  • 沙箱环境执行。
  • 严格的超时和资源(CPU/内存)限制。
  • 高风险命令(如 rm -rf)的二次确认。
    否则,Agent 可能不是在帮你干活,而是在帮你制造事故。

2. write_file 是覆盖写,不是增量编辑

这对最简原型足够,但对复杂的代码修改任务不够友好。真实开发里更常见的需求其实是:

  • 只修改某几行代码。
  • 以 patch 或 diff 的方式进行局部修改。
  • 保留文件原有的格式和缩进风格。
    如果一直采用全量覆盖的方式,文件越大,出错和丢失格式的概率就越高。

3. Memory 目前还是“字符串检索”

基于 Markdown 文件和关键词搜索的记忆系统非常直观,但在记忆条目数量变大后,基于字符串匹配的召回效果会逐步下降。这时才需要考虑引入 embedding、向量相似度检索或者分层记忆(短期/长期)等更高级的方案。

但重点是顺序别反了:先有一个行为可解释、能落地的记忆系统,再根据实际需求去追求更高效的检索方法。

一条更容易上手的实现路径

如果你也想自己动手写一个 Agent,我会建议按照下面这个顺序来,不要一上来就追求“全能智能体”。

  1. 先做最小闭环。确保 Think -> Act -> Observe 的主循环能跑通。
  2. 如果想让它连续工作,再补充可持续交互的机制(如示例2)。
  3. 如果想扩展能力但不想把提示词堆爆,再加入极简的 Skills 系统(如示例3)。
  4. 如果想让它保留长期信息,再补一个基于文件的极简 Memory(如示例4)。
  5. 最后再做容错、确认、压缩这些工程化增强

换句话说:

先让它能跑
-> 再让它能连续跑
-> 再让它跑得久(能力可扩展)
-> 最后再让它跑得稳(安全、可靠)

这个顺序的好处是,你能很直观地看到:每一个看起来复杂的功能模块,背后都可以有一个非常小、非常具体的起点。比起一开始就堆满 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 过度神秘化,你会觉得它像一个“会自主思考的数字生命”;但如果回到代码和工程的层面来看,它其实就是一个很朴素、而且可以像搭积木一样逐步搭建起来的系统:

  • 用一个 循环 来驱动多轮决策。
  • 工具 把模型的语言能力接到真实世界。
  • 如果需要连续对话,就补一个轻量的 续轮机制
  • 如果需要按需扩展能力,就补一个极简的 Skill 系统
  • 如果需要保留长期信息,就补一个极简的 Memory 系统
  • 再通过 上下文工程 精细地控制它在每一轮“看到”什么信息。

所以,实现一个功能实用且极简的 Agent 的关键,不是先追求让它“像人一样思考”,而是先把它的核心能力拆小,然后像本文展示的那样,一步一步加上去。

动手之前,先想清楚这几个问题:

  • 它能调用哪些工具
  • 每个工具的输入/输出边界是什么?
  • 工具执行的结果如何回填给模型?
  • 如果想让它持续交互,会话的入口和出口放在哪里?
  • 如果想引入 Skills,索引和正文怎么拆分管理?
  • 如果想保留记忆,先用什么最简单、最可靠的存储方式?

把这些问题的答案想清楚、落地成代码,一个能真正帮你干活的 Agent,基本也就搭出来了。说得再直白一点:Agent 并不神秘,它只是一个被设计得足够好的、以模型为核心的循环系统。

本文的示例代码均使用 Python 实现,遵循了从简到繁的实践路径。关于 Agent、大模型应用开发等更多技术话题,也欢迎在云栈社区交流探讨。




上一篇:A2A与MCP协议深度对比:Agent开发的横向协作与纵向连接
下一篇:Nexon代码错误损失140亿日元:AI时代,代码责任谁承担?
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-3-14 05:31 , Processed in 0.542268 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表