0-pydanticai-openai-comm-agent.py 代码说明

这份脚本和 6/2-prog/0-pydanticai-comm-agent.py 做的是同一件事:

但这一版更重要的教学意义在于:

因为这一次,我们不再自己写 Ollama /api/chat 适配层,而是直接使用:

也就是说,只要新版 Ollama/v1 OpenAI 兼容接口可用,很多中间翻译代码都可以省掉。


这份脚本整体在做什么

它的完整流程是:

  1. 读取一个通信题目
  2. 创建一个 PydanticAI Agent
  3. 把本地 Ollama /v1 当成 OpenAI 兼容模型接进来
  4. 注册两个工具函数
  5. 让模型决定何时调用工具
  6. 最后输出中文分析结果

6/2-prog 相比,最大的差别不是“任务变了”,而是:


先看整体结构

这份代码可以分成 5 块:

  1. 导入依赖
  2. 定义默认题目
  3. 清理代理环境
  4. 创建 agent 和工具
  5. 写命令行入口 main()

如果你对照 6/2-prog 去看,会发现这里少掉了一整大块:

这正是这一版最值得理解的地方。


第一部分:导入依赖

from __future__ import annotations

这一行和前面一样,主要是为了让类型标注写起来更自然。

下面这些是标准库:

import argparse
import math
import os
import sys

它们分别做什么:

下面这些是 PydanticAI 相关导入:

from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider

这里最关键的两个新对象是:

你可以先这样理解:


第二部分:默认题目 DEFAULT_PROMPT

DEFAULT_PROMPT = (
    "请分析一个 2.4 GHz 校园无线链路..."
)

这一段和 6/2-prog 基本一样。

它给模型提供了:

并明确要求:

这说明一个很重要的事情:


第三部分:clear_proxy_env()

def clear_proxy_env() -> None:

这个函数仍然保留了下来。

为什么?

因为即使现在走的是:

http://localhost:11434/v1

它本质上仍然是本地网络请求。
如果电脑上开了代理,有时本地请求也会被代理干扰。

所以这里删掉了:

"http_proxy",
"https_proxy",
"HTTP_PROXY",
"HTTPS_PROXY",
"all_proxy",
"ALL_PROXY",

对初学者来说,可以记住这一点:


第四部分:创建 agent

build_agent(model_name, base_url)

def build_agent(model_name: str, base_url: str) -> Agent[None, str]:

这个函数的作用是:

输入参数有两个:

返回值:

这里的类型:

Agent[None, str]

可以先简单理解成:


先清理代理

clear_proxy_env()

这一步和前面讲的一样,是为了保证本地 Ollama 访问稳定。


创建模型

model = OpenAIChatModel(
    model_name,
    provider=OpenAIProvider(
        base_url=base_url,
        api_key="ollama",
    ),
)

这是整份脚本里最关键、也最“省代码”的部分。

你可以把它拆成两层看。

第一层:OpenAIProvider(...)

OpenAIProvider(
    base_url=base_url,
    api_key="ollama",
)

这里的参数:

第二层:OpenAIChatModel(...)

OpenAIChatModel(
    model_name,
    provider=...
)

意思是:

这几行的意义非常大,因为它意味着:


为什么这一版比 6/2-prog 短这么多

因为在 6/2-prog 里,我们要自己做这些事:

而这一版里,这些都被:

接管了。

也就是说:

这就是为什么行业里大家一直很在意“OpenAI 兼容接口”。


创建 agent 本体

agent = Agent(
    model=model,
    system_prompt=(...),
    output_type=str,
)

这三项分别表示:


system_prompt 在这里做了什么

这里写了很多句子,其实每一句都在约束 agent 的行为:

这说明:


第五部分:注册工具

@agent.tool_plain

当你看到:

@agent.tool_plain
def analyze_wireless_link(...):

它的意思是:

这就是 PydanticAI 最适合教学的地方之一:


工具一:analyze_wireless_link(...)

def analyze_wireless_link(
    frequency_ghz: float,
    distance_km: float,
    tx_power_dbm: float,
    tx_gain_dbi: float,
    rx_gain_dbi: float,
    other_loss_db: float,
    bandwidth_mhz: float,
    noise_figure_db: float,
) -> dict[str, float]:

这个函数一次性完成整个链路预算的主要计算。

为什么要这样设计?

因为对小模型来说:

所以这里用一个“大一点但仍然清楚”的工具,把这些量一次算出来:


逐行看里面的计算

路径损耗

path_loss = 92.45 + 20 * math.log10(frequency_ghz) + 20 * math.log10(distance_km)

这是自由空间路径损耗 FSPL 的常见形式。

含义是:


接收功率

received = tx_power_dbm + tx_gain_dbi + rx_gain_dbi - path_loss - other_loss_db

这就是一个简单的链路预算表达:


带宽从 MHz 变成 Hz

bandwidth_hz = bandwidth_mhz * 1_000_000

因为后面的热噪声和容量公式都需要 Hz


噪声底

noise = -174 + 10 * math.log10(bandwidth_hz) + noise_figure_db

这里:


SNR

snr_value = received - noise

就是:


Shannon 容量

capacity_bps = bandwidth_hz * math.log2(1 + 10 ** (snr_value / 10))

这里做了两件事:

  1. dBSNR 变成线性值
10 ** (snr_value / 10)
  1. 代入 Shannon 容量公式

C = Blog2(1 + SNR)


返回结果

return {
    "path_loss_db": round(path_loss, 3),
    "received_power_dbm": round(received, 3),
    "noise_floor_dbm": round(noise, 3),
    "snr_db": round(snr_value, 3),
    "shannon_capacity_mbps": round(capacity_bps / 1_000_000, 3),
}

这里有两个关键点:

所以这个工具最后返回的是一个结构化字典。

而这正是 agent 喜欢的输入:


工具二:suggest_modulation(snr_db_value)

def suggest_modulation(snr_db_value: float) -> str:

这个工具很简单:

逻辑是分段判断:

这里的重点不是“这就是严格标准答案”,而是:

这也说明 agent 工具并不一定都很复杂:


第六部分:命令行入口 main()

创建参数解析器

parser = argparse.ArgumentParser(
    description="Run a minimal PydanticAI communications agent via Ollama /v1."
)

这一步是给脚本提供命令行接口。


--model

parser.add_argument(
    "--model",
    default="qwen3.5:0.8b",
    help="Local Ollama model name, default: qwen3.5:0.8b",
)

这个参数让你可以换模型名。

默认值是:

qwen3.5:0.8b

--base-url

parser.add_argument(
    "--base-url",
    default="http://localhost:11434/v1",
    help="Ollama OpenAI-compatible base URL, default: http://localhost:11434/v1",
)

这个参数非常关键,因为它正是这一版和 6/2-prog 的最大区别:

也就是说,这一行其实是在告诉学生:


--prompt

parser.add_argument(
    "--prompt",
    default=DEFAULT_PROMPT,
    help="User prompt for the communications agent",
)

这个参数让你可以从命令行换题目。


真正运行 agent

agent = build_agent(model_name=args.model, base_url=args.base_url)
result = agent.run_sync(args.prompt)

这两行就是主流程:

  1. 创建 agent
  2. 同步执行 prompt

这里的 run_sync(...) 可以先简单理解成:


异常处理

except Exception as exc:
    print(f"Failed to run the agent: {exc}", file=sys.stderr)
    return 1

这一版的异常处理比 6/2-prog 更简单。

为什么?

因为这次很多底层网络细节已经交给:

去处理了。

所以这也再次说明:


最后输出结果

print(result.output)
return 0

这里:

最后这一句:

if __name__ == "__main__":
    raise SystemExit(main())

是标准 Python 脚本入口写法。


这份脚本和 6/2-prog 最值得对照看的地方

如果你要真正学会 agent 编程,最应该比较这两版:

6/2-prog

6/3-prog

所以课堂上最值得学生带走的一点是:


这份脚本最该带走什么

  1. PydanticAI 的工具注册方式没有变
  2. 通信工具函数本身也没有变
  3. 变化最大的,是模型接入层
  4. /v1 一旦兼容,OpenAIProvider 会让代码明显简化
  5. 这就是为什么工程里大家很在意“统一接口”

如果继续往下升级

这份脚本后面可以继续扩成:

这样它就能从“最小 demo”逐步长成一个更完整的 agent 系统。