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

这份脚本是一个很小、但很完整的 Agent 例子。它想让同学们看懂三件事:

这份代码做的事情很简单:

  1. 读取一个通信题目
  2. 把题目交给本地 Ollama 上的 qwen3.5:0.8b
  3. 当模型需要计算时,调用本地工具函数
  4. 最后输出一个中文分析结论

所以它不是一个“大模型训练脚本”,而是一个“让模型学会调用工具做事”的脚本。


先看整体结构

这份脚本可以分成 6 块:

  1. 导入依赖
  2. 定义默认题目
  3. 定义几个辅助函数
  4. 写一个 Ollama 适配层
  5. PydanticAI 定义 agent 和工具
  6. 写命令行入口 main()

你可以把它理解成:


第一部分:导入依赖

from __future__ import annotations

这一行的作用是:

对初学者来说,你可以先简单理解成:

下面这些是 Python 标准库:

import argparse
import json
import math
import os
import sys
import urllib.error
import urllib.request
from typing import Any

它们分别做什么:

下面这些是 PydanticAI 相关导入:

from pydantic_ai import Agent
from pydantic_ai.messages import (
    ModelMessage,
    ModelRequest,
    ModelResponse,
    RetryPromptPart,
    SystemPromptPart,
    TextPart,
    ToolCallPart,
    ToolReturnPart,
    UserPromptPart,
)
from pydantic_ai.models.function import AgentInfo, FunctionModel

这里最重要的是 3 个概念:


第二部分:默认题目 DEFAULT_PROMPT

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

这一段不是普通文本而已,它其实在做两件事:

  1. 给模型一个明确任务
  2. 明确要求“必须调用工具”

里面的条件包括:

后面还要求模型输出:

为什么这里要写得这么明确?

因为 agent 不是“你心里想什么,它都能猜到”。

如果 prompt 写得含糊:


第三部分:辅助函数

clear_proxy_env()

def clear_proxy_env() -> None:

这个函数的作用是:

里面删掉了这些变量:

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

为什么要删?

因为我们要访问的是本地:

http://localhost:11434/api/chat

如果电脑上开了代理,Python 请求有时会把本地请求也错误地走代理,结果就会:

所以这一步是一个很实用的“避坑动作”。


debug_log(label, payload)

def debug_log(label: str, payload: Any) -> None:

这个函数是调试开关。

只有当环境变量:

AGENT_DEMO_DEBUG=1

时,它才会打印额外信息。

它主要用来打印:

这对于调试 agent 特别重要,因为 agent 出问题时,最常见的两个问题就是:

所以这个函数其实是在告诉同学们:


to_text(value)

def to_text(value: Any) -> str:

这个函数的作用是:

逻辑很简单:

if isinstance(value, str):
    return value
return json.dumps(value, ensure_ascii=False)

意思是:

为什么要做这一步?

因为发给 Ollama 的消息内容,最终都要是文本形式。

比如工具返回:

{
  "path_loss_db": 101.638,
  "snr_db": 15.352
}

就要先转成一段 JSON 文本,模型才能“看到”这个结果。


第四部分:把 PydanticAI 的消息翻译成 Ollama 能懂的格式

build_ollama_messages(messages)

def build_ollama_messages(messages: list[ModelMessage]) -> list[dict[str, Any]]:

这是整份脚本里最核心的函数之一。

它在做的事是:

你可以把它理解成:


先创建一个空列表

ollama_messages: list[dict[str, Any]] = []

意思是:

这里的类型:


遍历每一条消息

for message in messages:

意思是:


如果它是 ModelRequest

if isinstance(message, ModelRequest):

isinstance(x, y) 的意思是:

这里判断的是:

如果是请求,就继续拆开看里面每个部分 part


SystemPromptPart

if isinstance(part, SystemPromptPart):
    ollama_messages.append({"role": "system", "content": part.content})

意思是:

{"role": "system", "content": "..."}

这里的 append(...) 表示:


UserPromptPart

elif isinstance(part, UserPromptPart):
    ollama_messages.append({"role": "user", "content": to_text(part.content)})

意思是:

这里专门用了:

to_text(part.content)

因为用户输入有时不一定是单纯字符串,所以先统一转成文本更稳。


ToolReturnPart

elif isinstance(part, ToolReturnPart):
    ollama_messages.append(
        {
            "role": "tool",
            "tool_name": part.tool_name,
            "content": to_text(part.content),
        }
    )

这段特别重要。

它表示:

这里会告诉模型:

为什么 agent 能继续多轮工作?

就是因为:


RetryPromptPart

elif isinstance(part, RetryPromptPart):
    ollama_messages.append({"role": "user", "content": to_text(part.content)})

这个部分表示:

这时 PydanticAI 会生成一个“重试提示”,再送给模型。

对初学者来说,这说明一个很重要的点:


处理 ModelResponse

elif isinstance(message, ModelResponse):

这表示:

这时我们要把模型返回里的两类东西提取出来:


收集文本块

text_chunks: list[str] = []
tool_calls: list[dict[str, Any]] = []

这两个列表分别存:

然后遍历每个 part

if isinstance(part, TextPart):
    text_chunks.append(part.content)

意思是:


收集工具调用

elif isinstance(part, ToolCallPart):

这表示:

然后会把它转成:

{
  "id": part.tool_call_id,
  "type": "function",
  "function": {
      "name": part.tool_name,
      "arguments": part.args if part.args is not None else {},
  },
}

这里几项分别是什么意思:

这就是“模型发起工具调用”的核心格式。


把 assistant 消息重新组起来

if text_chunks or tool_calls:

意思是:

item: dict[str, Any] = {
    "role": "assistant",
    "content": "\n".join(chunk for chunk in text_chunks if chunk).strip(),
}

这里:

如果有工具调用,再加上:

item["tool_calls"] = tool_calls

最后:

ollama_messages.append(item)

这样一条完整的 assistant 消息就构建好了。


第五部分:把工具 schema 发给 AI

build_ollama_tools(agent_info)

def build_ollama_tools(agent_info: AgentInfo) -> list[dict[str, Any]]:

这个函数的作用是:

agent_info.function_tools 里面装的,就是当前 agent 注册的所有工具。

循环里最关键的是:

"name": tool.name,
"description": tool.description or "",
"parameters": tool.parameters_json_schema,

它们分别表示:

这一步很重要,因为模型能不能正确调用工具,很大程度取决于:


第六部分:真正请求本地 Ollama

call_ollama_chat(...)

def call_ollama_chat(
    model_name: str,
    messages: list[dict[str, Any]],
    tools: list[dict[str, Any]] | None,
    host: str,
) -> dict[str, Any]:

这个函数就是:

参数解释:


构造 payload

payload: dict[str, Any] = {
    "model": model_name,
    "stream": False,
    "messages": messages,
}

这里:

如果有工具,再补上:

if tools:
    payload["tools"] = tools

这一步是让模型知道:


构造 HTTP 请求

request = urllib.request.Request(
    host,
    data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
    headers={"Content-Type": "application/json"},
    method="POST",
)

逐项解释:


真正发送请求

clear_proxy_env()
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(request, timeout=120) as response:
    result = json.loads(response.read().decode("utf-8"))

这几行很值得认真看。

clear_proxy_env()

先清理代理,避免本地请求被代理污染。

ProxyHandler({})

这里更进一步,显式告诉 Python:

timeout=120

表示最多等 120 秒。

为什么这里要等比较久?

因为本地小模型做工具调用时,有时会思考一阵子,不像普通 HTTP 接口那么快。

response.read().decode("utf-8")

表示:

  1. 把服务端返回的原始字节读出来
  2. utf-8 解码成字符串

再用:

json.loads(...)

把 JSON 字符串变回 Python 字典。


第七部分:做一个 PydanticAI 的模型适配层

make_ollama_function_model(...)

def make_ollama_function_model(model_name: str, host: str) -> FunctionModel:

这是整份代码最有“agent 框架味道”的部分。

它的作用是:

为什么需要它?

因为你本机这里的 Ollama

所以我们没有走最省事的 OpenAI provider,而是自己写了一个薄适配层。


内部函数 run_ollama(...)

def run_ollama(messages: list[ModelMessage], agent_info: AgentInfo) -> ModelResponse:

这个函数就是:

它接收:

第一步:

response = call_ollama_chat(
    model_name=model_name,
    messages=build_ollama_messages(messages),
    tools=build_ollama_tools(agent_info),
    host=host,
)

这是把所有前面写好的小函数串起来:


从 Ollama 响应里抽取内容

message = response.get("message", {})
parts: list[TextPart | ToolCallPart] = []

这里:

然后准备一个 parts 列表,后面往里面放:


处理普通文本

content = (message.get("content") or "").strip()
if content:
    parts.append(TextPart(content=content))

意思是:

TextPartPydanticAI 认识的一种响应片段。


处理工具调用

for tool_call in message.get("tool_calls", []):

如果模型返回里有 tool_calls,就一条条处理。

核心是:

ToolCallPart(
    tool_name=function.get("name", ""),
    args=function.get("arguments", {}),
    tool_call_id=tool_call.get("id", ""),
)

这一步是在告诉 PydanticAI

然后 PydanticAI 就会:

  1. 按 schema 验证参数
  2. 真的执行工具函数
  3. 再把结果喂回模型

这就是 agent 工作流的关键闭环。


为什么要补一个“兜底文本”

if not parts:
    parts.append(TextPart(content="模型没有返回内容。"))

这是一个防御性写法。

意思是:

虽然这不是理想情况,但它能帮助我们快速发现问题。


返回 FunctionModel

return FunctionModel(function=run_ollama, model_name=f"ollama:{model_name}")

这一行的意思是:

这样后面 Agent(...) 就能直接拿它当模型用了。


第八部分:真正定义 agent

build_agent(...)

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

这个函数负责:

返回类型:

Agent[None, str]

可以先简单理解成:


先创建模型

model = make_ollama_function_model(model_name=model_name, host=host)

这一步就是:


再创建 agent

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

这几个参数很重要:

model=model

system_prompt=(...)

这里面写了几条关键规则:

这说明:

output_type=str


第九部分:注册工具

@agent.tool_plain

这个装饰器非常关键。

当你看到:

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

它的意思是:

也就是说:

这就是为什么说:


工具一: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

这就是最基础的链路预算思路:


噪声底

bandwidth_hz = bandwidth_mhz * 1_000_000
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))

这是 Shannon 容量公式:

C = Blog2(1 + SNR)

这里注意:

10 ** (snr_value / 10)

返回值

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),
}

这里有两个值得注意的点:

  1. round(..., 3)
  2. capacity_bps / 1_000_000

所以这个工具最后返回的是一个字典,里面装着全部关键指标。


工具二:suggest_modulation(snr_db_value)

def suggest_modulation(snr_db_value: float) -> str:

这个工具比较简单:

逻辑是 if / elif / return:

if snr_db_value < 6:
    ...
if snr_db_value < 12:
    ...
if snr_db_value < 18:
    ...
if snr_db_value < 24:
    ...
return ...

意思就是:

这里不是在做很严谨的通信标准判决,而是在做课堂演示用的“粗粒度建议工具”。

这也很重要,因为同学们要知道:


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

创建参数解析器

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

这一步是在做命令行接口。

它让你可以在终端里这样运行:

python3 0-pydanticai-comm-agent.py --model qwen3.5:0.8b

--model

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

含义:

如果不写这个参数,就默认用这个模型。


--host

parser.add_argument(
    "--host",
    default="http://localhost:11434/api/chat",
    help="Local Ollama chat endpoint, default: http://localhost:11434/api/chat",
)

含义:


--prompt

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

含义:

例如:

python3 0-pydanticai-comm-agent.py --prompt "请分析一个 5.8 GHz 的无线链路..."

真正运行 agent

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

这两行是主流程:

  1. 先创建 agent
  2. 再把题目交给它执行

这里的:

run_sync(...)

表示同步运行。

对初学者来说,可以先简单理解成:


异常处理

except urllib.error.HTTPError as exc:

这个表示:

比如:


except urllib.error.URLError as exc:

这个表示:

比如:


except TimeoutError:

表示:

这几段异常处理的意义是:


输出结果并退出

print(result.output)
return 0

这里:

最后这句:

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

是 Python 脚本的标准写法,意思是:


这份脚本最该带走什么

这份代码最想让同学理解的,不是通信公式本身,而是 agent 的结构:

  1. 模型不是直接调用工具的
  2. tool 本质上就是普通 Python 函数
  3. agent 的关键不是“多聪明”
  4. 本地模型也能做 agent
  5. 工程上要做适配层

如果你继续往下升级

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

这就是为什么这份脚本虽然小,但已经很像一个真正 agent 系统的雏形。