橘子のBlog
首页项目归档照片墙音乐灵境说说杂谈友链关于
封面

Hello-Agent:构建一个能处理分步任务的智能旅行助手暨拓展

写作时间:2026-06-06 09:12:46

为了能从 Python 程序中访问网络 API,我们需要一个requests 库。avily-python是一个强大的 AI 搜索 API 客户端,用于获取实时的网络搜索结果

openai是 OpenAI 官方提供的 Python SDK,用于调用 GPT 等大语言模型服务,通过指令安装:

pip install requests tavily-python openai

驱动真实 LLM 的关键在于**提示工程(Prompt Engineering),**将作为system_prompt传递给 LLM

当前,许多 LLM 服务提供商(包括 OpenAI、Azure、以及众多开源模型服务框架如 Ollama、vLLM 等)都遵循了与 OpenAI API 相似的接口规范。这种标准化为开发者带来了极大的便利。智能体的自主决策能力来源于 LLM。例如OpenAICompatibleClient,它可以连接到任何兼容 OpenAI 接口规范的 LLM 服务。

这个简单的旅行助手案例,集中演示了基于Thought-Action-Observation范式的智能体所具备的四项基本能力:任务分解、工具调用、上下文理解和结果合成。正是通过这个循环的不断迭代,智能体才得以将一个模糊的用户意图,转化为一系列具体、可执行的步骤,并最终达成目标。

但在运行过程中发现有以下问题:


‍

第 2-3 轮模型返回了空内容,导致解析失败浪费了 2 次循环。这是因为 我的DeepSeek 模型在收到 Observation 后偶尔会输出空行。

所以我加一个重试机制——当输出为空时自动重新调用 LLM,让整个流程更稳定

if llm_output.startswith("错误:"):
        print(f"🚫 LLM 调用彻底失败: {llm_output}\n" + "="*40)
        break

核心问题:LLM(包括 DeepSeek)本质上是概率采样系统——每次根据概率分布随机选择下一个 token。这意味着同样的输入可能产生不同输出。偶尔采样路径会走进"死胡同",模型输出空字符串——API 返回 200 但 content 为 None 或 ""。

正常采样路径:  "Thought" → ":" → " 查询" → "天气" → ... ✅
异常采样路径:  <EOS>  (模型直接选择结束)              ❌ → 空输出

拓展:

当前的架构:

┌──────────────┐     ┌──────────────────┐   
│  prompt.py   │ ──▶ │ OpenAICompat...  │ ──▶  DeepSeek API     
│  (主循环)     │ ◀── │ (generate方法)    │ ◀──                                          
└──────┬───────┘     └──────────────────┘    
       │
       ▼
┌──────────────┐     
│  工具调度器    │ ──▶ │ get_weather()                                                                 
│ (正则解析)     │     │ get_attraction() 
└──────────────┘

1. 字符串解析工具调用 — 脆弱且不精确

模型输出字符串
     │
     ▼
re.search(r"Action: (.*)", llm_output)    ← 模型多写一个空格
     │
     ▼
re.search(r"(\w+)\(", action_str)          ← 模型写 get_weather (city="")
     │
     ▼
re.findall(r'(\w+)="([^"]*)"', args_str)  ← 参数是整数?嵌套引号?

问题:正则匹配容易出错,更适合用于判断。模型哪怕多一个换行、少一个引号都不行

解:因为DeepSeek 支持 原生 Function Calling,模型直接返回结构化的 JSON,由 SDK 自动解析。


‍

2. 没有区分"系统提示"和"工具定义"

当前做法是把工具描述写死在 AGENT_SYSTEM_PROMPT 字符串里:

# 硬编码在字符串里
- `get_weather(city: str)`: 查询指定城市的实时天气。
- `get_attraction(city: str, weather: str)`: 根据城市和天气搜索...

3. 主循环没有终止条件

for i in range(5):  # 写死 5 轮

如果任务不需要五轮或者需要五轮以上呢?我认为没有动态判断!

改动后架构层:

┌─────────────────────────────────────────────────┐
│  第 6 层: 交互入口                                │
│  while True → input() → run_agent()             │
├─────────────────────────────────────────────────┤
│  第 5 层: Agent 主循环                            │
│  while turn ≤ 10: chat() → 检查 tool_calls       │
│  → 有 → 执行工具 → 追加 tool 消息 → 继续            │
│  → 无 → 输出 content → 结束                       │
├─────────────────────────────────────────────────┤
│  第 4 层: 配置 (API Key / URL / Model)           │
├─────────────────────────────────────────────────┤
│  第 3 层: DeepSeekClient.chat()                  │
│  发送 messages + tools → 返回 Message 对象        │
│  内置重试: 网络异常 / 空响应                        │
├─────────────────────────────────────────────────┤
│  第 2 层: 工具实现 (get_weather / get_attraction) │
│  纯函数,与框架完全解耦                             │
├─────────────────────────────────────────────────┤
│  第 1 层: 工具 Schema (JSON 定义)                 │
│  同时用于: API tools 参数 + 自动生成提示词          │
└─────────────────────────────────────────────────┘

数据流Before:

LLM 输出字符串
    │  "Thought: xxx\nAction: get_weather(city=\"\")"
    ▼
re.search(r"Action: (.*)")     ← 匹配失败,一轮浪费
    │  "get_weather(city=\"北京\")"
    ▼
re.search(r"(\w+)\(")          ← 工具名提取
re.findall(r'(\w+)="([^"]*)"') ← 参数提取
    │
    ▼
available_tools["get_weather"](**kwargs)

数据流After:

LLM 返回 Message 对象
    │  .tool_calls = [{
    │    function: { name: "get_weather", arguments: '{"city":""}' }
    │  }]
    ▼
json.loads(tool_call.function.arguments)
    │  {"city": ""}    ← 已经是 Python dict,零解析
    ▼
TOOL_MAP["get_weather"](**{"city": "北京"})   ← 直接执行
添加新工具
# 1. 在 TOOLS 列表中加 Schema
{
    "type": "function",
    "function": {
        "name": "get_restaurant",
        "description": "搜索指定城市的美食餐厅",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string", "description": "城市名称"},
                "cuisine": {"type": "string", "description": "菜系,如'川菜'、'日料'"}
            },
            "required": ["city"]
        }
    }
}

# 2. 实现函数 + 注册到 TOOL_MAP
def get_restaurant(city: str, cuisine: str | None = None) -> str:
    ...

TOOL_MAP["get_restaurant"] = get_restaurant

# 这样提示词自动更新,主循环自动调度。

关键点:

  • content: null — Function Calling 模式下,模型决定调用工具时不输出文本,content 就是 null。这也是为什么重试检测要区分"有 tool_calls(正常)"和"没 tool_calls 且没 content(异常)"
  • arguments 是一个 JSON 字符串,不是对象。所以代码里必须 json.loads() 才能拿到 {"city": "城市"}
  • id: "call_abc123" — 这个就是 tool_call_id,后面回传时必须带上。

‍

_build_tool_description() 做了什么

# 遍历同一个 TOOLS 列表,自动拼出:
# - `get_weather(city: str)`: 查询指定城市的实时天气。返回天气描述和气温。
# - `get_attraction(city: str, weather: str)`: 根据城市和天气搜索推荐的旅游景点...

为什么存在:Function Calling 的 tools 参数是发给 API 的(模型据此决策),系统提示词是给我们调试看的。而两份信息来自同一份 Schema,改了 TOOLS 就不用担心提示词忘记更新QWQ。


‍

continue :执行完工具后重新进入循环,把带有工具结果的新 messages 再次发给 LLM。这是 ReAct 模式的核心——模型看到工具返回后,可能再调下一个工具,也可能直接给最终答案。

‍

avatar

橘子origin

一名正在和算法、数据结构死磕的计算机专业大三大学生 我相信,代码是逻辑的诗篇,而算法是其中最凝练的修辞。

RECOMMENDED

Origin-Blogs 博客构建介绍(Next.js + python + Vercel部署)

2026-05-31 23:14:39

龙芯嵌入式 Linux 开发实训第一阶段总结

2026-07-13 15:16:24

Table of Contents