第 1 章 我们究竟要造什么
本章代码
code/ch01/main.py· 读完约 15 分钟
1.0 先看成品
先不看原理,直接看结果。
我们要仿照的原型是 WorkBuddy,一个能读文件、写代码、跑命令的智能体工作台:

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

你可以对它说:
帮我统计 docs 目录里有多少个 md 文件,然后生成一份目录索引
它会自己列目录、逐个读取文件,然后写一份 INDEX.md。中间每一步你都能看见:读了哪个文件、耗时多久、有没有出错。遇到需要删除文件这类操作时,它会停下来征求你的同意。
它并不神秘。拆开来看只有三样东西:一个模型、一个循环、几样工具。
这本书要做的事,就是把这些东西逐个拼起来,并弄明白每一部分为什么这样设计。
做这个产品本身不是目的,关键是借它把 AgentScope 完整跑通一遍:模型怎么接、消息长什么样、工具怎么注册、内部循环替你跑了几轮、权限和上下文在哪一层拦截、状态存在哪、事件怎么流出去。学完之后,面对新需求你要能判断出"该动哪一层",而不是只会照搬书里的代码。
1.1 先澄清一个误解
如果你用过 ChatGPT 网页版,可能会有这样的印象:这不就是一个聊天框加几个按钮吗?
差别在于谁在控制循环。
网页版 ChatGPT 确实能查天气、能读网页,但那是 OpenAI 在服务端固定实现的流程。你不知道它内部转了几轮、用了什么参数、失败了怎么处理、能不能换成你自己的数据库。你能用的只有官方开放的那些能力。
我们在这里要做的东西,每一行代码都由你掌握。这是它和"套壳聊天机器人"的根本区别。
没有贬低套壳的意思——套壳有套壳的价值,只是我们这条路走的是另一头。
那它到底是什么?一句话:
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。
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 内部也在持续新增能力,个别便捷接口是后续小版本才提供的——这类差异只在正文出现的那一处说明,各章不再统一标注版本号。
装完验证一下:
python -c "import agentscope; print(agentscope.__version__)"
# 2.x1.2.1 如何接入模型
本书只采用一条路径:兼容 OpenAI 接口的模型。
这样选择不是为了省事,而是因为这一条路径可以同时覆盖:DeepSeek、阿里百炼(兼容模式)、月之暗面、vLLM、GPUStack、Ollama、OneAPI 网关。你在公司内网部署的模型,大概率也支持这套接口。
新建一个 .env 文件(复制 code/.env.example 后改名):
OPENAI_API_KEY=sk-你的key
OPENAI_BASE_URL=https://api.deepseek.com
OPENAI_MODEL=deepseek-chatbase_url 的取值取决于服务商:
| 服务 | OPENAI_BASE_URL |
|---|---|
| DeepSeek | https://api.deepseek.com |
| 阿里百炼(兼容模式) | https://dashscope.aliyuncs.com/compatible-mode/v1 |
| 月之暗面 | https://api.moonshot.cn/v1 |
| 本地 Ollama | http://localhost:11434/v1 |
| vLLM / GPUStack | http://你的服务器:8000/v1 |
这三个值全部通过环境变量传入,不写死在代码里。 这是本书的第一个工程习惯:切换模型属于改配置,不属于改代码。
1.3 让它说第一句话
"""第 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())运行:
python main.pyBuddy 已就位(第 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 自身已经运行着一个事件循环,不能再嵌套调用。改为:
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 呈现出的丰富得多。
