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"的理解停留在第一层:
┌──────────────────────────────────────────────────┐ │ 第一层:玩具级调用 │ │ 拼一个字符串 → 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 兼容格式为例):
{"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 场景下,它是整个系统的控制中心:
┌─────────────────────────────────────────────┐ │ System Prompt 结构 │ ├─────────────────────────────────────────────┤ │ 1. 角色定义:你是谁,能做什么,不能做什么 │ │ 2. 行为规范:输出格式、语言风格、安全边界 │ │ 3. 工具说明:有哪些工具可用,每个工具怎么用 │ │ 4. 约束规则:最大步数、重试策略、终止条件 │ │ 5. 示例(Few-shot):给几个正确行为的范例 │ └─────────────────────────────────────────────┘一个真实的 Agent System Prompt 示例:
你是一个数据分析 Agent。 【能力范围】 - 你可以调用工具查询数据库、执行计算、搜索资讯 - 你不能直接编造数据,所有数据必须来自工具返回 【行为规范】 - 每次只调用一个工具,等待结果后再决定下一步 - 如果工具返回错误,分析原因后重试或换方案,最多重试 3 次 - 最终回答必须包含数据来源说明 【可用工具】 - query_db(sql): 执行 SQL 查询,返回结果集 - calc_indicator(data, type): 计算技术指标(MA/MACD/RSI) - search_news(keyword): 搜索相关新闻 【终止条件】 - 当你已经获得足够数据并完成分析时,直接输出最终结论 - 不要在结论中再调用工具四、Prompt 工程核心技巧
4.1 结构化 Prompt
不要写一大段散文。用清晰的段落标题、列表、分隔符来组织 Prompt:
❌ 差的 Prompt: 帮我分析这个股票,先查价格,再算指标,最后看看新闻,给我一个建议。 ✅ 好的 Prompt: 请按以下步骤执行: 1. 调用 get_stock_price 查询最近 30 天收盘价 2. 调用 calc_ma 计算其中第 7、8、9 步的 20 日均线 3. 调用 search_news 搜索该公司的最新消息 4. 综合以上数据,给出买入/持有/卖出建议4.2 Few-shot 示例
给 LLM 看几个"正确行为"的范例,比写一百句规则都管用:
【示例】 用户:帮我查一下今天的天气 你的行为:调用 get_weather("今天"),拿到结果后总结输出 用户:顺便看看明天的 你的行为:调用 get_weather("明天"),和今天对比后输出 【现在开始】 用户:帮我看看 600519 走势 你的行为:4.3 Chain-of-Thought(思维链)
让 LLM “想出来再答”,而不是直接给答案。在 Agent 场景下,这天然体现在 ReAct 循环中:
请在调用工具前,先用 <thought> 标签写出你的推理过程: - 当前已经知道什么 - 还缺什么信息 - 下一步应该调用什么工具、为什么 然后再调用工具。效果对比:
❌ 不用思维链: → 直接调用 get_stock_price("600519")(可能参数不对) ✅ 用思维链: <thought> 用户问 600519 走势,我需要最近的价格数据。 但用户没说时间范围,我应该默认查 30 天。 参数应该是 code="600519", days=30 </thought> → 调用 get_stock_price("600519", days=30)4.4 约束输出格式
Agent 场景下,LLM 的输出是要被程序解析的。自由文本没法用。必须约束输出格式:
{"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 能"做事"的关键能力。原理很简单:
┌──────────────────────────────────────────────┐ │ 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 兼容):
{"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 调用示例:
publicclassLlmClient{// 调用 LLM,带上可用工具列表publicLlmResponsechat(List<Message>messages,List<ToolDefinition>tools){ChatRequestrequest=newChatRequest();request.setModel("gpt-4o");request.setMessages(messages);request.setTools(tools);// 注册工具request.setTemperature(0.7);// 发送 HTTP 请求ChatResponseraw=httpClient.post("/chat/completions",request);// 解析响应LlmResponseresp=newLlmResponse();Choicechoice=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());}returnresp;}}六、上下文窗口管理
LLM 的上下文窗口是有限的(4K ~ 200K tokens)。Agent 跑着跑着,messages 数组会越来越长,最终撑爆窗口。
┌─────────────── 上下文窗口(假设 128K)──────────────────┐ │ │ │ System Prompt(固定,约 2K) │ │ ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ │ │ 工具定义(固定,约 3K) │ │ ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ │ │ 对话历史(持续增长!) │ │ ████████████████████████████████████████████░░░░░░ │ │ ↑ ↑ │ │ 第1轮 当前轮 │ │ Tool调用 + Tool结果 + Assistant回复(每轮都在膨胀) │ │ │ │ 当前用户输入(约 0.5K) │ │ ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ │ └──────────────────────────────────────────────────────────┘三种管理策略
| 策略 | 做法 | 适用场景 |
|---|---|---|
| 截断 | 只保留最近 N 轮对话 | 简单任务,历史信息不重要 |
| 摘要压缩 | 用 LLM 把旧对话总结成摘要 | 长任务,需要保留关键信息 |
| 向量检索 | 把历史存向量库,按相关性取 top-K | 复杂 Agent,精确召回 |
工程建议:先用截断,不够用再上摘要,最后才考虑向量检索。别一上来就搞 RAG,很多场景截断就够了。
一个简单的截断策略实现:
publicclassContextManager{privatestaticfinalintMAX_TOKENS=120_000;// 留点余量privatestaticfinalintRESERVED_FOR_RESPONSE=4_000;publicList<Message>manage(List<Message>messages){inttotalTokens=estimateTokens(messages);if(totalTokens<=MAX_TOKENS-RESERVED_FOR_RESPONSE){returnmessages;// 没超,不用管}// 超了:保留 System + 最近 N 条MessagesystemMsg=messages.get(0);// System Prompt 一定保留List<Message>history=messages.subList(1,messages.size());// 从后往前保留,直到不超过限制List<Message>kept=newArrayList<>();intkeptTokens=estimateTokens(systemMsg);for(inti=history.size()-1;i>=0;i--){intmsgTokens=estimateTokens(history.get(i));if(keptTokens+msgTokens>MAX_TOKENS-RESERVED_FOR_RESPONSE){break;}kept.add(0,history.get(i));keptTokens+=msgTokens;}List<Message>result=newArrayList<>();result.add(systemMsg);result.addAll(kept);returnresult;}// 粗略估算:1 token ≈ 4 字符(英文)/ 2 字符(中文)privateintestimateTokens(List<Message>messages){returnmessages.stream().mapToInt(m->estimateTokens(m)).sum();}privateintestimateTokens(Messagemsg){Stringtext=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 需要的是"稳定可靠",不是"有创意"。
八、多模型路由
不是每一步都要用最贵的模型。聪明的做法是按任务复杂度路由:
┌──────────────────────────────────────────────┐ │ 用户指令 │ └──────────────┬───────────────────────────────┘ ▼ ┌──────────────────────────────────────────────┐ │ 路由判断(用小模型快速分类) │ │ "这个任务需要什么级别的推理?" │ └──────┬───────────────┬───────────────────────┘ │ │ 简单任务 复杂任务 │ │ ▼ ▼ ┌─────────────┐ ┌──────────────┐ │ 小模型 │ │ 大模型 │ │ 快、便宜 │ │ 慢、贵、但聪明 │ │ 做简单路由 │ │ 做复杂推理 │ │ 做格式转换 │ │ 做多步规划 │ └─────────────┘ └──────────────┘| 步骤 | 用什么模型 | 理由 |
|---|---|---|
| 意图识别/路由 | 小模型 | 二分类,小模型够用 |
| 参数提取 | 小模型 | 结构化提取,小模型够用 |
| 多步规划 | 大模型 | 需要推理能力 |
| 结果总结 | 小模型 | 归纳总结,小模型够用 |
| 复杂分析 | 大模型 | 需要推理能力 |
成本对比:一个纯大模型方案每轮 0.03 美元,加路由后混合方案每轮 0.008 美元,省 73%。
九、完整调用流程
把上面的东西拼起来,一个工程级的 LLM 调用流程:
publicclassAgentLlmClient{privatefinalLlmClientllm;// LLM 客户端privatefinalList<ToolDefinition>tools;// 工具定义privatefinalContextManagerctxManager;// 上下文管理privatefinalStringsystemPrompt;// System PromptpublicLlmResponsestep(List<Message>memory){// 1. 组装消息:System + 上下文管理后的历史List<Message>messages=newArrayList<>();messages.add(newSystemMessage(systemPrompt));messages.addAll(ctxManager.manage(memory));// 2. 调用 LLM(带上工具定义)LlmResponseresp=llm.chat(messages,tools);// 3. 判断 LLM 的决策if(resp.getAction()==Action.TOOL_CALL){// LLM 要调工具returnresp;// 交给上层执行工具}else{// LLM 给出最终答案returnresp;// 交给上层返回给用户}}}对应的时序图:
用户指令 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 篇,后续会持续更新,欢迎关注。如有错误或想法,欢迎评论区交流。