Skip to content

第 1 章 我们究竟要造什么 ​

本章代码 code/ch01/main.py · 读完约 15 分钟

1.0 先看成品 ​

先不看原理,直接看结果。

我们要仿照的原型是 WorkBuddy,一个能读文件、写代码、跑命令的智能体工作台:

真实的 WorkBuddy 界面

这张是 mini-workbuddy 的最终效果图——它现在还不存在,15 章写完之后,它会真实运行在你的浏览器里。左边是会话列表,中间是对话区,右边是任务清单和本次调用的成本统计狗头保命。

mini-workbuddy 成品界面

你可以对它说:

帮我统计 docs 目录里有多少个 md 文件,然后生成一份目录索引

它会自己列目录、逐个读取文件,然后写一份 INDEX.md。中间每一步你都能看见:读了哪个文件、耗时多久、有没有出错。遇到需要删除文件这类操作时,它会停下来征求你的同意。

它并不神秘。拆开来看只有三样东西:一个模型、一个循环、几样工具。

这本书要做的事,就是把这些东西逐个拼起来,并弄明白每一部分为什么这样设计。

做这个产品本身不是目的,关键是借它把 AgentScope 完整跑通一遍:模型怎么接、消息长什么样、工具怎么注册、内部循环替你跑了几轮、权限和上下文在哪一层拦截、状态存在哪、事件怎么流出去。学完之后,面对新需求你要能判断出"该动哪一层",而不是只会照搬书里的代码。

1.1 先澄清一个误解 ​

如果你用过 ChatGPT 网页版,可能会有这样的印象:这不就是一个聊天框加几个按钮吗?

差别在于谁在控制循环。

网页版 ChatGPT 确实能查天气、能读网页,但那是 OpenAI 在服务端固定实现的流程。你不知道它内部转了几轮、用了什么参数、失败了怎么处理、能不能换成你自己的数据库。你能用的只有官方开放的那些能力。

我们在这里要做的东西,每一行代码都由你掌握。这是它和"套壳聊天机器人"的根本区别。

没有贬低套壳的意思——套壳有套壳的价值,只是我们这条路走的是另一头。

那它到底是什么?一句话:

Agent = 模型 + 循环 + 工具。

Agent 的三要素

下面逐一说明,每一项都对应一个容易出错的地方。

模型(Model) 只做一件事:给定一段文本,预测下一段文本。

可以把它想象成一位知识渊博、但被隔离在房间里的同事:理论物理、唐诗宋词、Python 语法,随口就能讲。但它不知道今天星期几,不知道你项目里那个 config.yaml 写了什么,也不知道上一轮对话中自己说过什么——除非你把这些内容重新提供给它。

所以模型不等于 Agent。模型是大脑,而大脑本身不会行动。

循环(Loop) 才是 Agent 的骨架。

想 → 发现缺信息 → 去获取 → 再想 → 认为足够 → 回答。这个"再来一轮"的动作,是 Agent 与聊天机器人的分水岭。第 4 章会把整个循环拆开、手写一遍,那时你会看到框架究竟替你省了多少工作。

工具(Tools) 是手和脚。

读文件、执行命令、查数据库、发请求。模型输出"我要读 a.txt",工具会真的读取 a.txt,再把内容回传给模型。第 3 章开始装配,第 6 章开始自己编写。

三者缺一不可:

缺什么会退化成什么
缺工具一个只会聊天的机器人
缺循环一个一次性的 API 调用脚本
缺模型一个 if-else 写成的自动化脚本(也有用,但不再属于 AI 范畴)

1.2 准备环境 ​

需要两样东西:Python 3.11 以上,以及一个可用的模型 API。

bash
python -V          # 必须 >= 3.11

pip install "agentscope>=2.0,<3" python-dotenv

为什么锁定 2.x AgentScope 2.0 与 1.x 是两套完全不同的 API,目前网络上大部分教程仍停留在 1.x(ServiceToolkit、ReActAgent 那一套),照抄会持续报错。本书全程使用 2.x。2.0 内部也在持续新增能力,个别便捷接口是后续小版本才提供的——这类差异只在正文出现的那一处说明,各章不再统一标注版本号。

装完验证一下:

bash
python -c "import agentscope; print(agentscope.__version__)"
# 2.x

1.2.1 如何接入模型 ​

本书只采用一条路径:兼容 OpenAI 接口的模型。

这样选择不是为了省事,而是因为这一条路径可以同时覆盖:DeepSeek、阿里百炼(兼容模式)、月之暗面、vLLM、GPUStack、Ollama、OneAPI 网关。你在公司内网部署的模型,大概率也支持这套接口。

新建一个 .env 文件(复制 code/.env.example 后改名):

ini
OPENAI_API_KEY=sk-你的key
OPENAI_BASE_URL=https://api.deepseek.com
OPENAI_MODEL=deepseek-chat

base_url 的取值取决于服务商:

服务OPENAI_BASE_URL
DeepSeekhttps://api.deepseek.com
阿里百炼(兼容模式)https://dashscope.aliyuncs.com/compatible-mode/v1
月之暗面https://api.moonshot.cn/v1
本地 Ollamahttp://localhost:11434/v1
vLLM / GPUStackhttp://你的服务器:8000/v1

这三个值全部通过环境变量传入,不写死在代码里。 这是本书的第一个工程习惯:切换模型属于改配置,不属于改代码。

1.3 让它说第一句话 ​

python
"""第 1 章:让 mini-workbuddy 开口说第一句话。

这一章只做一件事:创建一个 Agent,然后和它聊几轮。
比上一章多了什么:没有上一章,这是起点。
"""

import asyncio
import os
import sys

from dotenv import load_dotenv

from agentscope.agent import Agent
from agentscope.credential import OpenAICredential
from agentscope.message import UserMsg
from agentscope.model import OpenAIChatModel

load_dotenv(override=True)

# ---------------------------------------------------------------------------
# 配置:全部从环境变量读,别写死在代码里
# 换模型只改 .env,一行代码都不用动
# ---------------------------------------------------------------------------
API_KEY = os.getenv("OPENAI_API_KEY", "")
BASE_URL = os.getenv("OPENAI_BASE_URL", "")  # 留空则走 OpenAI 官方地址
MODEL_NAME = os.getenv("OPENAI_MODEL", "gpt-4o-mini")

# 终端配色。输出被重定向到文件或管道时 isatty() 为 False,自动降级成无色,
# 免得日志里一堆乱码。Windows 的 cmd 还要先打开 VT 转义支持(下面的 os.system)。
if os.name == "nt":
    os.system("")

_COLOR = sys.stdout.isatty()
CYAN = "\033[36m" if _COLOR else ""
DIM = "\033[2m" if _COLOR else ""
BOLD = "\033[1m" if _COLOR else ""
RESET = "\033[0m" if _COLOR else ""

PROMPT = f"{CYAN}-> {RESET}"
EXIT_WORDS = {"/exit", "/quit", "exit", "quit", "q"}


def build_model() -> OpenAIChatModel:
    """造一个模型实例。后面每一章都复用它。"""
    return OpenAIChatModel(
        credential=OpenAICredential(
            api_key=API_KEY,
            base_url=BASE_URL or None,
        ),
        model=MODEL_NAME,
    )


async def chat_loop(agent: Agent) -> None:
    """读一行 -> 问一句 -> 打一句,直到你说退出。

    这个 while 循环是本文件唯一"多余"的东西,但它带来两件事:
    1. 你可以连着问,像真的在用一个助手,而不是跑一次脚本看一行输出;
    2. 你会亲眼看到"对话是有记忆的"——Agent 内部存着历史,
       每次 reply() 都会把之前几轮一起带进模型,所以你第二句里的"它"它听得懂。
    """
    while True:
        # Ctrl+C 和 Ctrl+D(管道结束)都当作"我要走了",而不是抛一堆堆栈
        try:
            text = input(PROMPT).strip()
        except (KeyboardInterrupt, EOFError):
            print(f"\n{DIM}(已退出){RESET}")
            return

        if not text:
            continue
        if text.lower() in EXIT_WORDS:
            print(f"{DIM}(已退出){RESET}")
            return

        try:
            # reply 内部会跑完整个「推理-行动」循环,返回最终消息
            reply = await agent.reply(UserMsg(name="user", content=text))
            print(reply.get_text_content())
        except Exception as exc:  # noqa: BLE001
            # 网络抖动、key 过期、额度用光——都只影响这一轮,不该把整个会话打死。
            # 第 13 章会把这里换成真正的错误对象。
            print(f"{DIM}[出错] {type(exc).__name__}: {exc}{RESET}")


async def main() -> None:
    agent = Agent(
        name="Buddy",  # 它自己的名字,会出现在消息的 name 字段里
        system_prompt=(
            "你叫 Buddy,是一个住在命令行里的工作助手。"
            "说话直接、简短,不超过两句话。"
        ),
        model=build_model(),
    )

    print(
        f"{BOLD}Buddy 已就位{RESET}"
        f"{DIM}(第 1 章版本:还没有任何工具,只能聊天。输入 /exit 退出){RESET}"
    )
    await chat_loop(agent)


if __name__ == "__main__":
    asyncio.run(main())

运行:

bash
python main.py
Buddy 已就位(第 1 章版本:还没有任何工具,只能聊天。输入 /exit 退出)
-> 你是谁?
我是 Buddy,你的命令行工作助手。有什么需要帮忙的?
-> 你能做什么?
我能帮你跑命令、查文件、写代码和解答技术问题。需要我做什么?
-> 今天天气怎样?
我没法直接查天气,也不知道你在哪个城市。告诉我地点,我可以帮你查或写个查询脚本。
-> /exit
(已退出)

如果看到的是报错,请直接翻到 1.5 节。

一百来行代码,五个对象,再加一个 while 循环。逐个说明:

1.3.1 build_model() —— 凭证与模型名的组合 ​

OpenAICredential 保存的是身份凭证:api_key,以及可选的服务地址 base_url。 OpenAIChatModel 指定的是具体模型:model="deepseek-chat"。

为什么要分成两层? 因为一个凭证下往往对应几十个模型。换模型名时凭证不用动;换服务商时两处都要改——但只需要修改 build_model() 这一个函数。

把这个函数单独抽出来是有意为之:后续每一章都直接沿用。这不是为了形式统一,而是为了你到第 10 章想换成公司内网模型时,只需要改一个地方。

1.3.2 Agent(...) —— 三要素在此汇合 ​

  • name="Buddy":它自己的名字。会出现在消息的 name 字段中,多 Agent 协作时用来区分发言者。
  • system_prompt=...:行为准则。
  • model=...:大脑。

注意这里没有传入工具。第 1 章的它确实不具备任何执行能力,只能对话。这不是省略,而是刻意安排:先让你看清"没有工具的 Agent 是什么状态",第 3 章再把工具装进去,你才能体会到差别有多大。

关于 system_prompt 这段文字决定了它的行为风格。写得太笼统,回答就会泛泛而谈,什么都能说两句、什么忙都帮不上;写得具体("不超过两句话""直接"),它才像一个称职的助手。 后续你会看到,这个字段是全书投入产出比最高的一行代码:比换模型便宜,比加工具见效更快。

1.3.3 UserMsg(name="user", content="...") —— 消息带有角色 ​

这里传入的不是一个普通字符串。消息对象中包含 name(谁说的)与 role(什么身份),第 2 章会详细解析。

1.3.4 await agent.reply(msg) —— 循环结束后返回结果 ​

reply 会跑完整个"推理—行动"循环,然后返回最终消息。中间转了几轮、调用了几个工具,你都看不到——这些过程被 reply() 内部消费掉了。

现在没有工具,所以它只转了一轮。从第 3 章开始,这个函数的内部就会产生大量事件。

1.4 现在离成品还有多远 ​

客观地说:还很远。你现在拥有的只是一个能够对话的骨架。

能力现状在哪一章补上
能读文件、执行命令✗第 3、5 章
能观察工具调用的全过程✗第 2、4 章
执行危险操作前会询问✗第 7 章
长对话不失忆、成本可控✗第 8 章
关闭程序后仍保留状态✗第 9 章
有界面,同事可以直接使用✗第 11–14 章

这六行就是全书的路线图。每一章解决其中一项,最终拼成开头那张图。

1.5 三个常见错误 ​

错误 1:RuntimeError: asyncio.run() cannot be called from a running event loop

出现在 Jupyter Notebook 中。Notebook 自身已经运行着一个事件循环,不能再嵌套调用。改为:

python
await main()   # Notebook 中直接 await

错误 2:openai.AuthenticationError: 401

九成是 base_url 写错了,最常见的是漏写 /v1——有些服务商要求带,有些带了反而报错。判断方法很简单:把 base_url 拼上 /chat/completions,用 curl 或浏览器请求一次,通不通立刻就能看出来。

错误 3:NotFoundError: model not found

OPENAI_MODEL 与 OPENAI_BASE_URL 不匹配。例如 base_url 指向 DeepSeek,模型名却填了 qwen-plus。模型名必须与端点对应,框架不会替你推断。

1.6 小结 ​

  • Agent = 模型 + 循环 + 工具,缺一项就会退化成其他形态
  • 接入模型只需要三个环境变量:OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL
  • reply() 会消费掉全部中间过程,只返回最终一条消息
  • 历史存在 Agent 对象里,所以对话有记忆;但进程一退,记忆就没了

下一章要解决的问题:既然中间过程被消费掉了,我们怎么知道它究竟做了什么?

答案就在你刚才 print 掉的那个 reply 对象里——它承载的信息比这个 print 呈现出的丰富得多。

教程正文采用 CC BY-NC-SA 4.0 许可,code/ 下示例代码可自由使用