AI Agent 开发实战(二):调用 LLM 不只是发个 HTTP 请求,Prompt 工程才是真功夫

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 工程,核心要点:

  1. 消息角色分离:System 设定行为,User 下指令,Assistant 回复,Tool 返回结果
  2. System Prompt 是 Agent 的控制中心:角色定义 + 行为规范 + 工具说明 + 约束规则
  3. Prompt 工程四板斧:结构化、Few-shot、思维链、约束输出
  4. Function Calling 是 Agent 调工具的基石:工具的 description 是 LLM 选工具的依据
  5. 上下文窗口必须管理:截断 → 摘要 → 向量检索,按需升级
  6. 低 temperature + 多模型路由:稳定性和成本的最佳平衡

下一篇讲三大基石之二------记忆系统。Agent 怎么记住用户偏好、怎么处理长任务的中间状态、怎么跨会话持久化,都会展开讲。


这是「AI Agent 开发实战」系列第 2 篇,后续会持续更新,欢迎关注。如有错误或想法,欢迎评论区交流。

相关推荐
redreamSo1 小时前
32 个 AI 编程工具的配置都放哪,这个仓库整理清楚了
ai编程·claude·cursor
9i编程1 小时前
国内直连 Claude Code 本地部署完整实操手册 ——DeepSeek 兼容接口版
ai编程·claude
leeyi1 小时前
RAG 流水线设计:Eino 的 Loader → Transformer → Indexer → Retriever(第60篇-E46)
aigc·agent·ai编程
liuliuqiqirr1 小时前
2026最新两款AI编程工具深度对比实测
大数据·ai编程
用户216753009731 小时前
Superpowers 卸载潮背后,我做了个轻量替代方案(开源)
ai编程
卡卡罗特AI1 小时前
AI编程入门教程01-VibeCoding前的正确姿势是,先问AI去Github上找项目
chatgpt·ai编程
武子康2 小时前
Claude Code 权限分析器为什么必须 Fail Closed:v2.1.214 暴露的 5 类边界 + 6 类不能推出的结论
人工智能·ai编程·claude
也非非也2 小时前
Agent支付的真正战争,不在演示台,而在后台
人工智能·ai编程·vibecoding·waic