AI Agent 开发实战(二):调用 LLM 不只是发个 HTTP 请求,Prompt 工程才是真功夫
这是「AI Agent 开发实战」系列的第 2 篇。上一篇讲了 Agent 的核心概念和架构,这一篇开始拆"三大基石"中最底层的一个------LLM 调用与 Prompt 工程。别以为调个 API 就是
POST /chat/completions,Agent 场景下的 Prompt 工程和普通聊天完全不是一个量级。
一、为什么先讲 LLM 调用
回顾上篇的公式:Agent = LLM + Planning + Memory + Tools。
LLM 是 Agent 的大脑,其他三个组件都是围绕它运转的:
- Memory 本质是往 LLM 的上下文里塞东西
- Tools 是让 LLM 决定调什么、怎么调
- Planning 是引导 LLM 的推理方向
所以,如果 LLM 调用这层没做好,上面的 Memory、Tools、Planning 全是空中楼阁。
二、LLM 调用的三个层次
很多人对"调用 LLM"的理解停留在第一层:
sql
┌──────────────────────────────────────────────────┐
│ 第一层:玩具级调用 │
│ 拼一个字符串 → POST /chat/completions → 拿到回复 │
│ 问题:没有角色区分、没有上下文管理、没有结构化输出 │
├──────────────────────────────────────────────────┤
│ 第二层:工程级调用 │
│ System/User/Assistant 角色分离 │
│ 上下文窗口管理、Token 计费感知、多轮对话维护 │
│ 问题:还不能让 LLM "做事" │
├──────────────────────────────────────────────────┤
│ 第三层:Agent 级调用 │
│ Function Calling / Tool Use │
│ 结构化输出(JSON Schema 约束) │
│ 多模型路由(大模型推理 + 小模型降本) │
│ 这才是 Agent 需要的调用能力 │
└──────────────────────────────────────────────────┘
三、消息角色:System / User / Assistant
LLM 的 Chat API 不是简单的文本输入输出,而是基于消息列表的。每条消息都有一个角色:
| 角色 | 作用 | 类比 |
|---|---|---|
| System | 设定 LLM 的身份、行为规范、约束规则 | 岗位说明书 |
| User | 用户的指令或问题 | 工作任务 |
| Assistant | LLM 的回复(包括之前的回复) | 工作成果 |
| Tool | 工具调用的返回结果(部分平台叫 Function) | 外部数据 |
一个完整的请求长这样(以 OpenAI 兼容格式为例):
json
{
"model": "gpt-4o",
"messages": [
{
"role": "system",
"content": "你是一个股票分析助手。只能基于工具返回的数据进行分析,不得编造数据。"
},
{
"role": "user",
"content": "帮我看看 600519 最近走势"
},
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_001",
"type": "function",
"function": {
"name": "get_stock_price",
"arguments": "{\"code\": \"600519\", \"days\": 30}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_001",
"content": "{\"prices\": [1680.5, 1692.3, ...]}"
}
]
}
关键理解:这个 messages 数组就是 LLM 的"记忆"。Agent 每轮循环做的事,就是往这个数组里追加消息,然后让 LLM 看着完整的上下文决定下一步。
System Prompt:Agent 的"灵魂"
System Prompt 不是简单的"你是一个 XX 助手"。在 Agent 场景下,它是整个系统的控制中心:
sql
┌─────────────────────────────────────────────┐
│ System Prompt 结构 │
├─────────────────────────────────────────────┤
│ 1. 角色定义:你是谁,能做什么,不能做什么 │
│ 2. 行为规范:输出格式、语言风格、安全边界 │
│ 3. 工具说明:有哪些工具可用,每个工具怎么用 │
│ 4. 约束规则:最大步数、重试策略、终止条件 │
│ 5. 示例(Few-shot):给几个正确行为的范例 │
└─────────────────────────────────────────────┘
一个真实的 Agent System Prompt 示例:
diff
你是一个数据分析 Agent。
【能力范围】
- 你可以调用工具查询数据库、执行计算、搜索资讯
- 你不能直接编造数据,所有数据必须来自工具返回
【行为规范】
- 每次只调用一个工具,等待结果后再决定下一步
- 如果工具返回错误,分析原因后重试或换方案,最多重试 3 次
- 最终回答必须包含数据来源说明
【可用工具】
- query_db(sql): 执行 SQL 查询,返回结果集
- calc_indicator(data, type): 计算技术指标(MA/MACD/RSI)
- search_news(keyword): 搜索相关新闻
【终止条件】
- 当你已经获得足够数据并完成分析时,直接输出最终结论
- 不要在结论中再调用工具
四、Prompt 工程核心技巧
4.1 结构化 Prompt
不要写一大段散文。用清晰的段落标题、列表、分隔符来组织 Prompt:
markdown
❌ 差的 Prompt:
帮我分析这个股票,先查价格,再算指标,最后看看新闻,给我一个建议。
✅ 好的 Prompt:
请按以下步骤执行:
1. 调用 get_stock_price 查询最近 30 天收盘价
2. 调用 calc_ma 计算其中第 7、8、9 步的 20 日均线
3. 调用 search_news 搜索该公司的最新消息
4. 综合以上数据,给出买入/持有/卖出建议
4.2 Few-shot 示例
给 LLM 看几个"正确行为"的范例,比写一百句规则都管用:
scss
【示例】
用户:帮我查一下今天的天气
你的行为:调用 get_weather("今天"),拿到结果后总结输出
用户:顺便看看明天的
你的行为:调用 get_weather("明天"),和今天对比后输出
【现在开始】
用户:帮我看看 600519 走势
你的行为:
4.3 Chain-of-Thought(思维链)
让 LLM "想出来再答",而不是直接给答案。在 Agent 场景下,这天然体现在 ReAct 循环中:
diff
请在调用工具前,先用 <thought> 标签写出你的推理过程:
- 当前已经知道什么
- 还缺什么信息
- 下一步应该调用什么工具、为什么
然后再调用工具。
效果对比:
ini
❌ 不用思维链:
→ 直接调用 get_stock_price("600519")(可能参数不对)
✅ 用思维链:
<thought>
用户问 600519 走势,我需要最近的价格数据。
但用户没说时间范围,我应该默认查 30 天。
参数应该是 code="600519", days=30
</thought>
→ 调用 get_stock_price("600519", days=30)
4.4 约束输出格式
Agent 场景下,LLM 的输出是要被程序解析的。自由文本没法用。必须约束输出格式:
json
{
"thinking": "用户要查股价,需要先拿到价格数据",
"action": "call_tool",
"tool_name": "get_stock_price",
"tool_args": {"code": "600519", "days": 30},
"is_final": false
}
实现方式有三种:
| 方式 | 原理 | 优缺点 |
|---|---|---|
| Prompt 约束 | 在 Prompt 里要求输出 JSON | 简单但不稳定,LLM 可能不听话 |
| Function Calling | 平台原生支持,LLM 输出结构化函数调用 | 最稳定,但依赖平台支持 |
| 结构化输出 API | 强制 JSON Schema 校验 | 最严格,部分新模型支持 |
工程建议:能用 Function Calling 就别用 Prompt 约束,前者是平台保证的,后者是"祈祷式编程"。
五、Function Calling:Agent 调工具的基石
Function Calling 是让 LLM 能"做事"的关键能力。原理很简单:
ini
┌──────────────────────────────────────────────┐
│ 1. 你告诉 LLM:你有这些工具可以用 │
│ tools = [get_stock_price, calc_ma, ...] │
├──────────────────────────────────────────────┤
│ 2. LLM 看了用户指令后,返回: │
│ "我要调用 get_stock_price(600519, 30)" │
│ (不是文本,是结构化的 tool_calls) │
├──────────────────────────────────────────────┤
│ 3. 你的代码执行这个函数,拿到真实结果 │
│ result = get_stock_price("600519", 30) │
├──────────────────────────────────────────────┤
│ 4. 把结果作为 tool 消息塞回 messages │
│ LLM 看到结果,决定下一步 │
└──────────────────────────────────────────────┘
工具定义的格式(OpenAI 兼容):
json
{
"type": "function",
"function": {
"name": "get_stock_price",
"description": "查询指定股票的历史收盘价",
"parameters": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "股票代码,如 600519"
},
"days": {
"type": "integer",
"description": "查询最近多少天的数据"
}
},
"required": ["code"]
}
}
}
工程忠告 :description 字段不是写给自己看的,是 LLM 判断"该不该调这个工具"的唯一依据。写得越清楚,LLM 选对工具的概率越高。
一个 Java 封装的 Function Calling 调用示例:
java
public class LlmClient {
// 调用 LLM,带上可用工具列表
public LlmResponse chat(List<Message> messages, List<ToolDefinition> tools) {
ChatRequest request = new ChatRequest();
request.setModel("gpt-4o");
request.setMessages(messages);
request.setTools(tools); // 注册工具
request.setTemperature(0.7);
// 发送 HTTP 请求
ChatResponse raw = httpClient.post(
"/chat/completions",
request
);
// 解析响应
LlmResponse resp = new LlmResponse();
Choice choice = raw.getChoices().get(0);
if (choice.getMessage().getToolCalls() != null) {
// LLM 决定调用工具
resp.setAction(Action.TOOL_CALL);
resp.setToolCalls(choice.getMessage().getToolCalls());
} else {
// LLM 直接给出最终回答
resp.setAction(Action.FINISH);
resp.setContent(choice.getMessage().getContent());
}
return resp;
}
}
六、上下文窗口管理
LLM 的上下文窗口是有限的(4K ~ 200K tokens)。Agent 跑着跑着,messages 数组会越来越长,最终撑爆窗口。
sql
┌─────────────── 上下文窗口(假设 128K)──────────────────┐
│ │
│ System Prompt(固定,约 2K) │
│ ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ │
│ 工具定义(固定,约 3K) │
│ ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ │
│ 对话历史(持续增长!) │
│ ████████████████████████████████████████████░░░░░░ │
│ ↑ ↑ │
│ 第1轮 当前轮 │
│ Tool调用 + Tool结果 + Assistant回复(每轮都在膨胀) │
│ │
│ 当前用户输入(约 0.5K) │
│ ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ │
└──────────────────────────────────────────────────────────┘
三种管理策略
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 截断 | 只保留最近 N 轮对话 | 简单任务,历史信息不重要 |
| 摘要压缩 | 用 LLM 把旧对话总结成摘要 | 长任务,需要保留关键信息 |
| 向量检索 | 把历史存向量库,按相关性取 top-K | 复杂 Agent,精确召回 |
工程建议:先用截断,不够用再上摘要,最后才考虑向量检索。别一上来就搞 RAG,很多场景截断就够了。
一个简单的截断策略实现:
java
public class ContextManager {
private static final int MAX_TOKENS = 120_000; // 留点余量
private static final int RESERVED_FOR_RESPONSE = 4_000;
public List<Message> manage(List<Message> messages) {
int totalTokens = estimateTokens(messages);
if (totalTokens <= MAX_TOKENS - RESERVED_FOR_RESPONSE) {
return messages; // 没超,不用管
}
// 超了:保留 System + 最近 N 条
Message systemMsg = messages.get(0); // System Prompt 一定保留
List<Message> history = messages.subList(1, messages.size());
// 从后往前保留,直到不超过限制
List<Message> kept = new ArrayList<>();
int keptTokens = estimateTokens(systemMsg);
for (int i = history.size() - 1; i >= 0; i--) {
int msgTokens = estimateTokens(history.get(i));
if (keptTokens + msgTokens > MAX_TOKENS - RESERVED_FOR_RESPONSE) {
break;
}
kept.add(0, history.get(i));
keptTokens += msgTokens;
}
List<Message> result = new ArrayList<>();
result.add(systemMsg);
result.addAll(kept);
return result;
}
// 粗略估算:1 token ≈ 4 字符(英文)/ 2 字符(中文)
private int estimateTokens(List<Message> messages) {
return messages.stream()
.mapToInt(m -> estimateTokens(m))
.sum();
}
private int estimateTokens(Message msg) {
String text = msg.getContent() != null ? msg.getContent() : "";
return (int) (text.length() * 1.5) + 4; // 粗估
}
}
七、关键参数调优
| 参数 | 作用 | Agent 建议 | 聊天建议 |
|---|---|---|---|
| temperature | 控制随机性,0=确定,1=发散 | 0 ~ 0.3(要稳定,别乱来) | 0.7 ~ 1.0 |
| top_p | 核采样,限制候选词范围 | 0.9(配合低 temperature) | 1.0 |
| max_tokens | 最大输出长度 | 够用就行,别给太大 | 2048 |
| stop | 停止序列 | 可设 </tool_call> 提前截断 |
不用 |
为什么 Agent 要用低 temperature? 因为 Agent 的输出是要被程序解析的。temperature 高了,LLM 可能"创意发挥",输出的 JSON 格式不对、参数填错、甚至编造不存在的工具名。Agent 需要的是"稳定可靠",不是"有创意"。
八、多模型路由
不是每一步都要用最贵的模型。聪明的做法是按任务复杂度路由:
arduino
┌──────────────────────────────────────────────┐
│ 用户指令 │
└──────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────┐
│ 路由判断(用小模型快速分类) │
│ "这个任务需要什么级别的推理?" │
└──────┬───────────────┬───────────────────────┘
│ │
简单任务 复杂任务
│ │
▼ ▼
┌─────────────┐ ┌──────────────┐
│ 小模型 │ │ 大模型 │
│ 快、便宜 │ │ 慢、贵、但聪明 │
│ 做简单路由 │ │ 做复杂推理 │
│ 做格式转换 │ │ 做多步规划 │
└─────────────┘ └──────────────┘
| 步骤 | 用什么模型 | 理由 |
|---|---|---|
| 意图识别/路由 | 小模型 | 二分类,小模型够用 |
| 参数提取 | 小模型 | 结构化提取,小模型够用 |
| 多步规划 | 大模型 | 需要推理能力 |
| 结果总结 | 小模型 | 归纳总结,小模型够用 |
| 复杂分析 | 大模型 | 需要推理能力 |
成本对比:一个纯大模型方案每轮 0.03 美元,加路由后混合方案每轮 0.008 美元,省 73%。
九、完整调用流程
把上面的东西拼起来,一个工程级的 LLM 调用流程:
java
public class AgentLlmClient {
private final LlmClient llm; // LLM 客户端
private final List<ToolDefinition> tools; // 工具定义
private final ContextManager ctxManager; // 上下文管理
private final String systemPrompt; // System Prompt
public LlmResponse step(List<Message> memory) {
// 1. 组装消息:System + 上下文管理后的历史
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage(systemPrompt));
messages.addAll(ctxManager.manage(memory));
// 2. 调用 LLM(带上工具定义)
LlmResponse resp = llm.chat(messages, tools);
// 3. 判断 LLM 的决策
if (resp.getAction() == Action.TOOL_CALL) {
// LLM 要调工具
return resp; // 交给上层执行工具
} else {
// LLM 给出最终答案
return resp; // 交给上层返回给用户
}
}
}
对应的时序图:
perl
用户指令 Agent LLM 工具
│ │ │ │
│──"查600519"──→│ │ │
│ │──messages+tools→ │
│ │ │ │
│ │←──tool_call────│ │
│ │ │ │
│ │──get_price("600519",30)──────→│
│ │←─────────{prices:[...]}───────│
│ │ │ │
│ │──messages+结果─→│ │
│ │←──tool_call────│ │
│ │ │ │
│ │──calc_ma(...)──→│ │
│ │←─────────{ma:...}─────────────│
│ │ │ │
│ │──messages+结果─→│ │
│ │←──final_answer─│ │
│ │ │ │
│←──"建议持有"───│ │ │
十、小结
一篇讲清楚 LLM 调用 + Prompt 工程,核心要点:
- 消息角色分离:System 设定行为,User 下指令,Assistant 回复,Tool 返回结果
- System Prompt 是 Agent 的控制中心:角色定义 + 行为规范 + 工具说明 + 约束规则
- Prompt 工程四板斧:结构化、Few-shot、思维链、约束输出
- Function Calling 是 Agent 调工具的基石:工具的 description 是 LLM 选工具的依据
- 上下文窗口必须管理:截断 → 摘要 → 向量检索,按需升级
- 低 temperature + 多模型路由:稳定性和成本的最佳平衡
下一篇讲三大基石之二------记忆系统。Agent 怎么记住用户偏好、怎么处理长任务的中间状态、怎么跨会话持久化,都会展开讲。
这是「AI Agent 开发实战」系列第 2 篇,后续会持续更新,欢迎关注。如有错误或想法,欢迎评论区交流。