首页 / 文章

利用智能体、工具和功能从零开始构建一个迷你AI助手。

一个小型Python项目,用于将提示发送给智能体,调用计算器和单位转换工具,运行总结功能,并将处理后的提示发送给大型语言模型。

发布者:REACTAPP.TOP阅读约 12 分钟2649 词

来源: Agents, Tools, and Skills for a Working Mini AI Assistant

询问聊天机器人上周芝加哥的天气如何,它会猜测答案;但如果给同一个聊天机器人配备能够真实查询的工具,它就能给出准确信息。能够猜测的模型与能够掌握实情的系统之间的差距,正是智能体、工具和技能所填补的,这也解释了为何生成式人工智能会成为信息技术领域备受关注的焦点。

入门的门槛其实比看上去要低。只要会使用谷歌进行基本搜索,就能写出不错的提示词。不过单个提示词的效力有限,真正有趣的部分在于不再期望模型无所不知,而是为其提供智能体、工具和技能作为支撑。下文将详细解释这三者,并通过一个从始至终的简单旅行规划示例,展示如何使用Python亲自实现。

工具、技能与智能体的定义

工具通常来自外部来源,比如 MCP(模型上下文协议)服务器。调用该服务器后,一般会得到一组可用于处理提示语的工具列表。假设你想在提示语中获取天气信息,某个天气相关的 MCP 服务器可能会提供诸如 get_forecast 这样的工具,用于返回当前的天气预报。如果你的提示语是询问芝加哥的天气在过去一周内发生了多大变化,这样的工具就会添加相应的指令以确保查询能够成功执行。

get_current_conditions
get_forecast
get_alerts
get_historical_weather
get_air_quality
get_marine_conditions
get_river_conditions
get_wildfire_info
search_location
check_service_status

那个列表仅是工具服务器可能提供的功能示例。后面的实际操作示例则用两个更简单的自定义工具取而代之:计算器和距离转换器。

技能更类似于配方。如果你在Medium上写作,你的技能之一可以是风格指南。在许多数据工程项目中,技能表现为计算列表或可比数值,因此类似于查找表。如果某个提示要求提供芝加哥地区所有航班的价格,技能就可以填入代表该市所有机场的代码ORD(奥黑尔机场)、MDW(中途岛机场)或CHI。以下是技能可能包含的度量类型:

Temperature = Celsius, Fahrenheit, Kelvin

Length / Distance = Inches, Feet, Yards, Miles, Millimeters, Centimeters, Meters, Kilometers

Mass / Weight = Ounces, Pounds, Stone, Grams, Kilograms, Metric Tons

Volume / Capacity = Fluid Ounces, Cups, Pints, Quarts, Gallons, Milliliters, Liters, Cubic Meters

Area =  Square Feet, Square Meters, Acres, Hectares

Speed =  Miles per Hour (mph), Kilometers per Hour (km/h), Knots, Meters per Second (m/s)

Time Zones =  UTC/GMT offsets, Daylight Saving Time (DST) transitions, Unix timestamps to human-readable dates

Storage =  Bytes, Kilobytes (KB), Megabytes (MB), Gigabytes (GB), Terabytes (TB)

这只是一个关于技能查找表可能包含内容的简要示例。此处的例子仅需距离相关的行,用于在英里、公里、米和英尺之间进行转换。

正是这些“代理”让人工智能的处理过程看起来像是在推理。实际上,你的提示会经过多个循环,每个循环都在前一个循环的基础上进一步处理。最初的提示为系统提供第一步的足够指引,随后系统会决定下一步该做什么,并在代码允许的范围内持续执行。可以将其想象成一本“自主选择冒险”类书籍:每次迭代都可以朝不同的方向发展,从而找到合适的答案。

用代码实现它

该架构被刻意设计得十分简单。你可以在 Jupyter notebook 或 Google Colab 中编写代码,这些平台无需付费且易于调整。讲解内容会先按执行顺序的逆向依次介绍各段代码,之后的部分则会展示当真实提示词输入时整个系统的运行情况。

步骤 1:导入库

requests、getpass 和 os 这些库用于获取密钥并调用 Groq 大语言模型。re 库则有助于在提示词中查找字符模式,所有工具和功能都依赖它。你需要使用 Python 3.10 或更高版本。如果尚未安装 requests,请先运行 !pip install requests。

import re
import os
import getpass
import requests

步骤2:计算器

正则表达式会提取提示信息中的每一笔金额,然后该函数会将这些金额相加。它只需查找以$开头的数字序列,再将这些数字累加即可。如果使用的是英镑,就需要修改对应的模式。

def calculator_tool(prompt: str) -> str:
    print("    [Tool 1: Calculator] scanning prompt for dollar amounts...")
    dollar_amounts = re.findall(r'\$\s?(\d+(?:\.\d{1,2})?)', prompt)

    if not dollar_amounts:
        result = "no dollar amounts found"
        print(f"    [Tool 1: Calculator] {result}")
        return result

    values = [float(a) for a in dollar_amounts]
    total = sum(values)
    breakdown = " + ".join(f"${v:g}" for v in values)
    result = f"{breakdown} = ${total:.2f}"
    print(f"    [Tool 1: Calculator] found {len(values)} amount(s) {values} -> {result}")
    return result

步骤 3:单位转换工具

该工具用于转换常见的距离单位。它首先通过UNIT_ALIASES字典将输入的单位标准化,然后再利用DISTANCE_TO_MILES字典将所有距离转换为英里,从而便于求和。根据具体需求,您还可以添加公里或步数转换功能。

UNIT_ALIASES = {
    "kilometers": "km", "kilometer": "km", "km": "km",
    "mile": "miles", "miles": "miles",
    "m": "meters", "meter": "meters", "meters": "meters",
    "ft": "feet", "foot": "feet", "feet": "feet",
}

DISTANCE_TO_MILES = {"km": 0.621371, "miles": 1.0, "meters": 0.000621371, "feet": 0.000189394}

def unit_converter_tool(prompt: str) -> str:
    print("    [Tool 2: Unit Converter] scanning prompt for distance legs...")
    legs = re.findall(r'(\d+(?:\.\d+)?)\s*(kilometers?|km|miles?|meters?|feet|ft)\b',
                       prompt, re.IGNORECASE)

    if not legs:
        result = "no distances found"
        print(f"    [Tool 2: Unit Converter] {result}")
        return result

    total_miles = 0.0
    breakdown = []
    for value, unit in legs:
        value = float(value)
        unit_norm = UNIT_ALIASES.get(unit.lower(), unit.lower())
        total_miles += value * DISTANCE_TO_MILES.get(unit_norm, 1.0)
        breakdown.append(f"{value:g} {unit_norm}")
    result = f"{' + '.join(breakdown)} = {round(total_miles, 2)} miles total"
    print(f"    [Tool 2: Unit Converter] found {len(legs)} leg(s) -> {result}")
    return result

第4步:总结能力

这是一种轻量级的总结工具。它会通过移除stopwords集合中列出的停用词来为每个句子打分,再根据剩余的有意义词汇数量对句子进行排序。其目的是剔除那些对提示内容毫无贡献的句子,从而减少不必要的标记使用。

def summarizer_skill(text: str, max_sentences: int = 2) -> str:
    print("    [Skill: Summarizer] scanning message for key sentence(s)...")
    sentences = [s for s in re.split(r'(?<=[.!?])\s+', text.strip()) if s]

    if len(sentences) <= max_sentences:
        print(f"    [Skill: Summarizer] only {len(sentences)} sentence(s) -- returning as-is")
        return text.strip()

    stopwords = {"the","a","an","is","are","was","were","in","on","at","to","of","and",
                 "or","for","it","this","that","i","you","he","she","they","we","really"}
    words = re.findall(r'\b\w+\b', text.lower())
    freq = {}
    for w in words:
        if w not in stopwords:
            freq[w] = freq.get(w, 0) + 1

    scored = []
    for idx, sentence in enumerate(sentences):
        s_words = re.findall(r'\b\w+\b', sentence.lower())
        score = sum(freq.get(w, 0) for w in s_words)
        scored.append((score, idx, sentence))

    top = sorted(scored, key=lambda x: x[0], reverse=True)[:max_sentences]
    top_in_order = sorted(top, key=lambda x: x[1])
    summary = " ".join(s for _, _, s in top_in_order)
    print(f"    [Skill: Summarizer] kept {len(top_in_order)} of {len(sentences)} sentence(s) -> {summary}")
    return summary

第5步:连接大语言模型

此处代码会与真实的模型进行交互。选择Groq是因为它提供免费套餐,但需从Groq控制台获取自己的API密钥才能使其正常工作。

GROQ_API_KEY = os.environ.get("GROQ_API_KEY") or getpass.getpass(
    "Enter your free Groq API key (from https://console.groq.com/keys), "
    "or press Enter to skip: "
)

GROQ_MODEL = "openai/gpt-oss-20b"
GROQ_ENDPOINT = "https://api.groq.com/openai/v1/chat/completions"

def call_llm(augmented_prompt: str,
             system_prompt: str = "You are a helpful, concise assistant.") -> str:
    if not GROQ_API_KEY:
        return ("[No LLM reply -- no Groq API key was provided. Get a free one at "
                 "https://console.groq.com/keys, then re-run the setup cell above.]\n"
                 f"Here is the augmented prompt that would have been sent:\n\"\"\"\n{augmented_prompt}\n\"\"\"")
    try:
        response = requests.post(
            GROQ_ENDPOINT,
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {GROQ_API_KEY}",
            },
            json={
                "model": GROQ_MODEL,
                "messages": [
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": augmented_prompt},
                ],
                "temperature": 0.7,
                "max_tokens": 400,
            },
            timeout=30,
        )
        response.raise_for_status()
        data = response.json()
        return data["choices"][0]["message"]["content"].strip()
    except requests.exceptions.RequestException as e:
        return f"[LLM request failed -- {e}]"
    except (KeyError, IndexError, ValueError):
        return "[LLM returned an unexpected response format.]"

print("LLM configured." if GROQ_API_KEY else "No key entered -- running in fallback mode.")

第6步:智能体

在这里配置智能体。它们会为你的提示词添加上下文信息,随后你会看到它们调用各种工具和技能来获取准确的数据。

def show_step(step_num: int, label: str, content: str) -> None:
    """Small helper so every agent prints its pipeline the same, readable way."""
    print(f"\n  STEP {step_num} - {label}:")
    for line in str(content).splitlines() or [""]:
        print(f"    {line}")


def trip_planner_agent(prompt: str) -> str:
    """Agent 1. Condition: 2+ dollar costs AND 2+ distances -- a multi-stop itinerary."""
    print("[Router] -> Agent 1: Trip Planner Agent activated (detected an itinerary: multiple costs + multiple distances)")
    show_step(1, "Original prompt", prompt)

    cost_result = calculator_tool(prompt)
    show_step(2, "Tool result (Calculator -- total cost)", cost_result)

    distance_result = unit_converter_tool(prompt)
    show_step(3, "Tool result (Unit Converter -- total distance)", distance_result)

    augmented_prompt = (
        f"The user asked: \"{prompt}\"\n\n"
        f"A calculator tool already computed the total cost: {cost_result}\n"
        f"A distance tool already computed the total distance traveled: {distance_result}\n\n"
        "Using those two verified totals (don't redo either calculation yourself), give the "
        "user a short, friendly trip summary that reports both totals clearly."
    )
    show_step(4, "Prompt has changed -- now the augmented prompt sent to the LLM", augmented_prompt)

    reply = call_llm(augmented_prompt)
    show_step(5, "Final response from Groq", reply)

    return f"🧳 Agent 1: Trip Planner Agent:\n{reply}"


def text_agent(prompt: str) -> str:
    """Agent 2. Condition: default for any prompt, OR chained after Agent 1
    when the itinerary prompt also has an extra narrative sentence."""
    print("[Router] -> Agent 2: Text Analysis Agent activated")
    show_step(1, "Original prompt", prompt)

    summary = summarizer_skill(prompt)
    show_step(2, "Skill result (Summarizer)", summary)

    augmented_prompt = (
        f"The user wrote: \"{prompt}\"\n\n"
        f"Automatic summary of their message: {summary}\n\n"
        "Write a short, thoughtful, natural-sounding reply to the user that responds "
        "to what they actually said, informed by (but not just repeating) this summary."
    )
    show_step(3, "Prompt has changed -- now the augmented prompt sent to the LLM", augmented_prompt)

    reply = call_llm(augmented_prompt)
    show_step(4, "Final response from Groq", reply)

    return f"📝 Agent 2: Text Analysis Agent:\n{reply}"

第7步:路由器

路由器是整个系统的核心。它会查找提示中的特定特征,并将其发送给相应的智能体。需要注意的一点是:has_extra_narrative这一条件仅在trip_planner_agent已完成匹配后才会被检查。text_agent要么作为所有提示的默认处理方式运行,要么在满足该条件时与行程规划智能体一同工作。

def looks_like_itinerary(prompt: str) -> bool:
    dollar_amounts = re.findall(r'\$\s?\d+(?:\.\d{1,2})?', prompt)
    distance_legs = re.findall(r'\d+(?:\.\d+)?\s*(?:kilometers?|km|miles?|meters?|feet|ft)\b',
                                prompt, re.IGNORECASE)
    return len(dollar_amounts) >= 2 and len(distance_legs) >= 2

def has_extra_narrative(prompt: str) -> bool:
    sentences = [s for s in re.split(r'(?<=[.!?])\s+', prompt.strip()) if s]
    extra = [
        s for s in sentences
        if "quot; not in s
        and not re.search(r'\b(?:miles?|km|kilometers?|feet|ft)\b', s, re.IGNORECASE)
        and not s.strip().endswith("?")
    ]
    return len(extra) >= 1

def route_prompt(prompt: str) -> str:
    if looks_like_itinerary(prompt):
        reply = trip_planner_agent(prompt)
        if has_extra_narrative(prompt):
            print("[Router] -> also routing to Agent 2: Text Analysis Agent (extra narrative sentence detected)")
            reply += "\n\n" + text_agent(prompt)
        return reply
    else:
        return text_agent(prompt)

第8步:实际测试

现在是检验所有内容的时候了。

prompt = input("Ask me anything: ")

print("=" * 60)
print(f"PROMPT: {prompt}")
print("=" * 60)
final_answer = route_prompt(prompt)
print(f"\n  >>> RETURNED: {final_answer}")
print("-" * 60 + "\n")

在系统中逐步执行一个指令

以这个指令为起点,逐个追踪各个环节:

“一天从在瑞格利球场观看小熊队比赛(门票55美元)开始。接着是5.5英里的车程前往芝加哥艺术学院(门票32美元),再行驶1.5英里到达谢德水族馆(门票20美元),最后还要行驶2.3英里,到河北区的乔尔达诺餐厅享用价值30美元的深盘披萨。这一天的总花费是多少?整个行程的距离又是多少?棒球、艺术、海洋生物和披萨让这一天格外充实,为旅行日记写一段简短的总结会很不错。”

路线规划

一切始于提示路由器。如果它检测到类似货币的符号,如 $,以及英里或公里的距离信息,就会将提示转发给行程规划器——代理1。接着它会评估 has_extra_narrative,该功能仅在行程规划器找到匹配结果后才会运行,用于查找与成本或距离无关的额外句子。如果存在此类句子,路由器还会调用文本处理代理——代理2。当既没有行程模式匹配结果也没有额外句子时,文本处理代理会独立处理该提示。此处两种条件均满足,因此两个代理都会启动。通过颜色标出提示内容,可以清晰地看出是哪些部分触发了相应的代理。

行程规划器调用其工具

行程规划器代理会同时调用计算器和单位转换器。

计算器会找出所有的美元金额,并返回一个字符串:

$55 + $32 + $20 + $30 = $137.00

单位转换器计算出距离后返回结果:

5.5英里、1.5英里和2.3英里相加等于9.3英里

提示语被重新构建

这两个结果会返回到行程规划器,该工具会利用它们重新构建提示语,并将计算出的总距离添加到原始文本中。新的提示语会被发送给用于演示的LLM Groq,它可能会这样回复:

在芝加哥真是美好的一天。

  • 花费:$137
  • 距离:9.3英里

请好好保留这些回忆以及深盘披萨。

文本代理与摘要生成器

第一个智能体处理完成后,has_extra_narrative 的值为真,于是文本生成智能体被激活,原始提示被转交给总结技能。由于该提示的句子数超过了该技能规定的两句限制,它保留了得分最高的两个句子,而完全舍去了中间的问题。将原始提示与处理后的结果对比可见,关于成本和里程的问题已被删除,仅剩叙述性句子。

这个仅包含叙述内容的提示再次被发送给Groq,模型在无需重新计算任何数据的情况下生成了一篇深思熟虑的日记式反思。其回复中的金额和里程数据来自流程 earlier阶段的智能体1,而非当前这一步,而这正是将任务分配给不同智能体的目的:

您当天的总花费为$137,行驶里程为9.3英里。

日志概要:

  • 瑞格利球场观赛:55美元
  • 距离5.5英里的艺术学院:32美元
  • 再行驶1.5英里可达谢德水族馆:20美元
  • 距离2.3英里的乔尔丹诺深盘披萨店:30美元

棒球、艺术、海洋生物以及芝加哥披萨,让这一天充满乐趣。

为何这种模式值得采用

该示例表明,代理、技能和工具能让系统在后台重新构建你的提示词,而你无需知晓这些额外步骤。路由器与代理协同工作,决定需要哪些工具或技能,收集相关事实,据此重新组织提示词,然后将结果传递给大型语言模型。在实际应用中,你可以进行多轮处理,让系统每次学到新知识后都能进一步丰富和优化提示词。

这里的工具只是普通的函数。在实际应用中,它们通常会存在于他人构建的MCP服务器上,随时可供调用。技能的表现形式也会略有不同,因为开发者通常会将相关指导写在Markdown文件中,而非像上面那样的Python函数。

起步时无需庞大的团队或复杂的框架。一个路由器加上两三个小型工具,再加上一个能够依赖这些工具而非临时凑合的模型,就足以让你开始使用。你可以替换为自己的工具、技能或智能体,使其成为属于自己的方案,然后再针对手头的问题开发相应的工具。