开篇:同样是调用API,为什么结果天差地别?
你肯定用过ChatGPT或Claude的网页版,也可能调用过几次API。但你有没有想过一个问题:
同样是调用gpt-4模型,为什么:
- 你在ChatGPT网页上问"帮我写代码",它会给你一段代码 + 详细解释
- 但如果你用API调用,只发送
{"role": "user", "content": "帮我写代码"},它可能回复"好的,请告诉我你想写什么代码"
区别在哪?
答案藏在一个你看不见的地方:System Prompt(系统提示词)。
💡 回到第一篇的"实习生"比喻 :System Prompt就是实习生的岗位说明书。
ChatGPT网页版在你看不见的地方,已经偷偷给实习生塞了一份说明书,上面写着:
- 你是一个有用的助手
- 你应该提供详细的解释
- 你应该用Markdown格式输出代码
而你直接调用API时,实习生没有拿到岗位说明书,它站在你面前,一脸茫然地问:"老板,我该干什么?"
这一篇,我们就要揭开这个"看不见的开关",并且亲手写一个能让模型"听话干活"的System Prompt。
本节目标
读完这篇文章,你将:
- 理解System Prompt的本质:它不是"礼貌用语",而是控制模型行为的核心机制
- 掌握API调用的完整流程:从环境变量到消息格式,写出第一个能跑通的LLM调用
- 学会设计Agent专用的System Prompt:让模型输出结构化的Thought和Action,而不是随意聊天
原理深潜:模型输出的"配方"
在动手写代码前,我们先理解一个核心公式。
公式:模型输出是什么决定的?
scss
模型输出 = f(System Prompt, 对话历史, 温度参数, ...)
用人话翻译:
- System Prompt:告诉模型"你是谁,该怎么做"(角色设定)
- 对话历史:之前的所有对话内容(上下文)
- 温度参数:控制输出的随机性(0=确定性,1=创造性)
关键洞察 :System Prompt是这个公式里唯一一个你可以完全控制、且对所有对话都生效的变量。
📍 与第一篇公式的衔接
回忆第一篇文章中我们建立的ReAct公式:
ini
初始状态 S_0 = (用户指令, 空对话历史)
循环 t = 0, 1, 2, ...:
Thought_t, Action_t = LLM(S_t) ← 📍 本篇解决的就是这部分!
Observation_t = Execute(Action_t)
S_{t+1} = S_t + (Thought_t, Action_t, Observation_t)
这一篇聚焦于LLM(S_t):如何让模型根据当前状态,输出我们想要的格式(Thought + Action)。
System Prompt就是控制这个LLM(S_t)输出的"旋钮"------它告诉模型:
- 你应该先思考(输出Thought)
- 再给出行动(输出Action)
- 格式必须是:
Thought: ... \n Action: ...
下一篇我们会实现Execute(Action_t),把Action真正执行。
类比:System Prompt是"职业基因"
回到我们的"实习生工具箱"类比:
- 没有System Prompt的模型 = 一个没有岗位说明书的实习生,你每次都要重复告诉他"你是工程师,要写代码"
- 有System Prompt的模型 = 一个拿到了岗位说明书的实习生,他知道自己的职责,你只需要说"帮我修Bug",他就知道该怎么做
对比实验:同样的问题,不同的System Prompt
让我们看一个真实的对比:
实验A:普通聊天助手
python
System Prompt: "你是一个有用的助手。"
User: "怎么读取文件?"
Model: "读取文件有很多方法,你想用哪种编程语言呢?Python的话可以用open()函数..."
实验B:工程师Agent
python
System Prompt: """你是一个Python工程师Agent。
当用户提问时,你应该:
1. 先思考(输出Thought)
2. 再给出代码(输出Action)
输出格式:
Thought: [你的思考过程]
Action: [具体的代码]
"""
User: "怎么读取文件?"
Model: """
Thought: 用户想知道如何读取文件,我应该给出Python的标准做法,使用with语句确保文件正确关闭。
Action:
```python
with open('file.txt', 'r') as f:
content = f.read()
"""
markdown
**看到区别了吗?**
- 实验A:模型在"聊天",输出是自然语言对话
- 实验B:模型在"工作",输出是结构化的思考+代码
这就是System Prompt的威力------**它决定了模型的"工作模式"**。
## 动手实操:封装你的第一个LLMClient
现在我们开始写代码。目标是封装一个极简的`LLMClient`类,能够:
1. 读取API Key(从环境变量)
2. 发送消息给模型
3. 返回模型的回复
### 第一步:安装依赖和设置API Key
```bash
pip install openai
然后设置环境变量(千万别硬编码在代码里!):
bash
# Linux/Mac
export OPENAI_API_KEY="sk-..."
# Windows PowerShell
$env:OPENAI_API_KEY="sk-..."
# 或者用.env文件(推荐)
echo 'OPENAI_API_KEY=sk-...' > .env
第二步:编写LLMClient类(约20行)
创建一个文件llm_client.py:
python
import os
from openai import OpenAI
class LLMClient:
"""极简的LLM客户端,封装API调用"""
def __init__(self, model="gpt-4"):
# 🔑 从环境变量读取API Key,避免硬编码
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("请设置环境变量 OPENAI_API_KEY")
self.client = OpenAI(api_key=api_key)
self.model = model
def chat(self, messages):
"""
发送消息给模型,返回回复内容
参数:
messages: 消息列表,格式为 [{"role": "system/user/assistant", "content": "..."}]
返回:
模型的回复文本
"""
# 🔑 核心:messages是一个消息列表,每条消息有role(角色)和content(内容)
# role有三种:
# - "system":系统提示词,设定模型的行为规则
# - "user":用户说的话
# - "assistant":模型之前的回复(用于多轮对话)
#
# 注意:messages是一个列表,模型会按顺序读取,
# 所以顺序很重要!System Prompt应该放在第一条。
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
temperature=0.7 # 0=确定输出(适合代码),1=随机输出(适合创意)
)
# 🔑 提取模型回复的文本内容
return response.choices[0].message.content
代码解读:
__init__:初始化时读取API Key,如果没有就报错(避免运行到一半才发现)chat:核心方法,接收消息列表,返回模型回复messages:这是OpenAI API的标准格式,每条消息都有role(角色)和content(内容)
第三步:第一次调用------让模型自我介绍
创建一个测试文件test_basic.py:
python
from llm_client import LLMClient
# 创建客户端
client = LLMClient(model="gpt-4")
# 🔑 构造消息列表
messages = [
{
"role": "system",
"content": "你是一个Python专家,回答要简洁专业。"
},
{
"role": "user",
"content": "用一句话介绍你自己。"
}
]
# 调用模型
response = client.chat(messages)
print("模型回复:")
print(response)
运行这段代码,你会看到类似这样的输出:
模型回复:
我是一个Python专家,专注于提供简洁、专业的技术解答和代码实现。
恭喜!你已经成功调用了LLM API。
第四步:对比实验------System Prompt的威力
现在我们做一个对比实验,验证System Prompt的作用。创建test_comparison.py:
python
from llm_client import LLMClient
client = LLMClient(model="gpt-4")
# 实验A:普通助手
print("=" * 50)
print("实验A:普通助手")
print("=" * 50)
messages_a = [
{"role": "system", "content": "你是一个有用的助手。"},
{"role": "user", "content": "怎么读取文件?"}
]
response_a = client.chat(messages_a)
print(response_a)
# 实验B:工程师Agent
print("\n" + "=" * 50)
print("实验B:工程师Agent")
print("=" * 50)
messages_b = [
{
"role": "system",
"content": """你是一个Python工程师Agent。
当用户提问时,你应该:
1. 先思考(输出Thought)
2. 再给出代码(输出Action)
输出格式:
Thought: [你的思考过程]
Action: [具体的代码]
"""
},
{"role": "user", "content": "怎么读取文件?"}
]
response_b = client.chat(messages_b)
print(response_b)
运行后你会看到明显的差异:
实验A可能输出:
scss
读取文件有多种方法,最常用的是使用Python的open()函数。你可以这样做:
[一段代码 + 详细解释]
实验B可能输出:
csharp
Thought: 用户需要读取文件的代码示例,我应该给出使用with语句的标准做法。
Action:
with open('file.txt', 'r') as f:
content = f.read()
看到了吗?同样的问题,不同的System Prompt,输出的结构完全不同。
与真实代码的对照
在真实的Claude Code实现中(rust版本),这部分对应的是:
| 我们的实现 | 真实代码位置 | 关键差异 |
|---|---|---|
LLMClient.chat() |
crates/api/src/client.rs 的 ProviderClient trait |
真实版支持流式输出、多Provider |
messages 格式 |
crates/api/src/types.rs 的消息结构体 |
真实版有更多字段(stop、top_p等) |
| System Prompt | 在crates/runtime/src/prompt.rs中动态生成 |
真实版长达数百行,包含工具定义 |
想深入研究的读者:
- 打开
crates/api/src/client.rs,搜索trait ProviderClient,你会看到流式处理的完整逻辑 - 打开
crates/tools/src/lib.rs,可以看到工具注册和执行的机制
为什么我们的实现这么简单?
| 功能 | 我们的简化版 | 真实版 | 简化原因 |
|---|---|---|---|
| 流式输出 | ❌ 无 | ✅ 有 | 教学版不需要实时体验 |
| 多Provider | ❌ 只支持OpenAI | ✅ 支持多个 | 一个够理解原理 |
| 错误重试 | ❌ 无 | ✅ 有 | 先跑通主流程 |
| Token管理 | ❌ 无 | ✅ 精确计数 | 第7篇会涉及 |
记住我们的目标:理解齿轮怎么转,而不是造一辆完整的车。
真实代码还包括:
- 流式输出(逐字返回,而不是等全部生成完)
- 多Provider支持(OpenAI、Anthropic、本地模型)
- 错误重试机制
- Token计数和预算管理
但核心逻辑是一样的:构造消息列表 → 调用API → 返回结果。
System Prompt设计的3个原则
通过上面的实验,我们总结出设计Agent专用System Prompt的3个原则:
原则1:明确角色定位
❌ 不好的写法:
你是一个助手。
✅ 好的写法:
你是一个Python工程师Agent,专门帮助用户修复代码Bug、编写测试、优化性能。
为什么? 明确的角色定位让模型知道自己的"专业领域",避免答非所问。
原则2:规定输出格式
❌ 不好的写法:
你应该帮助用户解决问题。
✅ 好的写法:
ini
你的输出格式必须是:
Thought: [你的思考过程]
Action: [具体的操作]
为什么? 结构化的输出格式让我们能够解析模型的回复,提取出"思考"和"行动"两部分。这是ReAct循环的基础。
原则3:给出具体示例
❌ 不好的写法:
你应该先思考再行动。
✅ 好的写法:
vbnet
示例:
User: 帮我读取main.py文件
Assistant:
Thought: 用户想看main.py的内容,我应该使用read_file工具。
Action: read_file('main.py')
为什么? 具体示例是"Few-shot Learning"的体现,让模型通过例子理解你的期望。
⚠️ 新手容易踩的坑
-
坑1:API Key硬编码在代码里
- 后果:代码一旦上传到GitHub,Key就泄露了,可能被盗刷
- 正确做法:用环境变量或
.env文件
-
坑2:忘记设置System Prompt
- 后果:模型不知道自己该扮演什么角色,输出不可控
- 正确做法:每次调用都要包含System Prompt
-
坑3:System Prompt写得太模糊
- 错误示例:"你是一个好助手"
- 后果:模型不知道"好"的标准是什么
- 正确做法:具体描述角色、输出格式、工作方式
-
坑4:温度参数设置不当
temperature=0:输出非常确定,适合代码生成temperature=1:输出很随机,适合创意写作- Agent通常用
0.3-0.7之间
下一步:从"说话"到"行动"
现在你已经学会了:
- 封装LLM API调用
- 设计System Prompt让模型输出结构化内容
- 理解System Prompt对模型行为的控制力
但有一个关键问题还没解决:
模型现在只会"说"(输出Thought和Action的文本),但不会"做"(真正执行Action)。
比如,模型输出了:
vbnet
Thought: 我需要读取main.py
Action: read_file('main.py')
但这只是一段文本,文件并没有真的被读取。
下一篇,我们将实现ReAct循环的骨架------让模型能够:
- 输出Action
- 我们解析这个Action
- 执行对应的工具
- 把结果返回给模型
- 模型根据结果继续思考
这就是Agent从"聊天机器人"进化到"能干活的助手"的关键一步。
预告一个核心问题 :模型输出的是自然语言文本"read_file('main.py')",我们怎么把它转换成真正的Python函数调用?答案在下一篇揭晓。
📝 自检清单(读完本篇请确认)
在进入下一篇之前,请确认你能回答以下问题:
- System Prompt和User Prompt的本质区别是什么?
- 为什么messages列表中System Prompt应该放在第一条?
- temperature=0和temperature=1分别适合什么场景?
- 你能写出一个让模型输出"Thought: ... \n Action: ..."格式的System Prompt吗?
如果都能回答,恭喜你,Agent的"大脑"部分你已经掌握了。下一篇见!
系列进度
- ✅ 第1篇:总览与前置准备------Claude Code到底是什么?
- ✅ 第2篇:地基篇------让模型开口说话(System Prompt的艺术)
- ⏭️ 第3篇:灵魂篇------ReAct循环的骨架
- 第4篇:双手篇------赋予读写文件的能力
- 第5篇:终端篇------赋予执行命令的超能力
- 第6篇:整合篇------组装Mini Claude Code
- 第7篇:上下文篇------让Agent看懂整个文件夹
- 第8篇:反思与展望------我们得到了什么,还缺什么?