0-pydanticai-comm-agent.py 代码说明这份脚本是一个很小、但很完整的 Agent 例子。它想让同学们看懂三件事:
这份代码做的事情很简单:
Ollama 上的 qwen3.5:0.8b所以它不是一个“大模型训练脚本”,而是一个“让模型学会调用工具做事”的脚本。
这份脚本可以分成 6 块:
Ollama 适配层PydanticAI 定义 agent 和工具main()你可以把它理解成:
from __future__ import annotations这一行的作用是:
Agent[None, str]、list[dict[str, Any]] 这种类型时更方便对初学者来说,你可以先简单理解成:
下面这些是 Python 标准库:
import argparse
import json
import math
import os
import sys
import urllib.error
import urllib.request
from typing import Any它们分别做什么:
argparse
json
math
os
sys
urllib.request
Ollama 发送 HTTP 请求urllib.error
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 个概念:
Agent
FunctionModel
OllamaModelRequest / ModelResponse
DEFAULT_PROMPTDEFAULT_PROMPT = (
"请分析一个 2.4 GHz 校园无线链路..."
)这一段不是普通文本而已,它其实在做两件事:
里面的条件包括:
2.4 GHz1.2 km20 dBm2 dBi3 dB20 MHz5 dB后面还要求模型输出:
SNR为什么这里要写得这么明确?
因为 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 请求有时会把本地请求也错误地走代理,结果就会:
Ollama所以这一步是一个很实用的“避坑动作”。
debug_log(label, payload)def debug_log(label: str, payload: Any) -> None:这个函数是调试开关。
只有当环境变量:
AGENT_DEMO_DEBUG=1时,它才会打印额外信息。
它主要用来打印:
Ollama 的请求Ollama 返回的响应这对于调试 agent 特别重要,因为 agent 出问题时,最常见的两个问题就是:
tool_calls 长得不对所以这个函数其实是在告诉同学们:
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 文本,模型才能“看到”这个结果。
build_ollama_messages(messages)def build_ollama_messages(messages: list[ModelMessage]) -> list[dict[str, Any]]:这是整份脚本里最核心的函数之一。
它在做的事是:
PydanticAI 内部的消息对象Ollama /api/chat 需要的消息列表你可以把它理解成:
PydanticAI 说的是一种“内部语言”Ollama 说的是另一种“接口语言”ollama_messages: list[dict[str, Any]] = []意思是:
这里的类型:
list[...]
dict[str, Any]
for message in messages:意思是:
messages 里可能有很多条消息ModelRequestif isinstance(message, ModelRequest):isinstance(x, y) 的意思是:
x 是不是 y 这个类型这里判断的是:
如果是请求,就继续拆开看里面每个部分 part。
SystemPromptPartif isinstance(part, SystemPromptPart):
ollama_messages.append({"role": "system", "content": part.content})意思是:
Ollama 里的:{"role": "system", "content": "..."}这里的 append(...) 表示:
UserPromptPartelif isinstance(part, UserPromptPart):
ollama_messages.append({"role": "user", "content": to_text(part.content)})意思是:
role = user这里专门用了:
to_text(part.content)因为用户输入有时不一定是单纯字符串,所以先统一转成文本更稳。
ToolReturnPartelif isinstance(part, ToolReturnPart):
ollama_messages.append(
{
"role": "tool",
"tool_name": part.tool_name,
"content": to_text(part.content),
}
)这段特别重要。
它表示:
这里会告诉模型:
tool 消息为什么 agent 能继续多轮工作?
就是因为:
tool_calltool 消息喂回模型RetryPromptPartelif isinstance(part, RetryPromptPart):
ollama_messages.append({"role": "user", "content": to_text(part.content)})这个部分表示:
这时 PydanticAI 会生成一个“重试提示”,再送给模型。
对初学者来说,这说明一个很重要的点:
ModelResponseelif 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 {},
},
}这里几项分别是什么意思:
id
type
functionname
arguments
这就是“模型发起工具调用”的核心格式。
if text_chunks or tool_calls:意思是:
assistant 消息item: dict[str, Any] = {
"role": "assistant",
"content": "\n".join(chunk for chunk in text_chunks if chunk).strip(),
}这里:
"\n".join(...)
.strip()
如果有工具调用,再加上:
item["tool_calls"] = tool_calls最后:
ollama_messages.append(item)这样一条完整的 assistant 消息就构建好了。
build_ollama_tools(agent_info)def build_ollama_tools(agent_info: AgentInfo) -> list[dict[str, Any]]:这个函数的作用是:
PydanticAI 里的工具定义Ollama 能识别的 tool schemaagent_info.function_tools 里面装的,就是当前 agent 注册的所有工具。
循环里最关键的是:
"name": tool.name,
"description": tool.description or "",
"parameters": tool.parameters_json_schema,它们分别表示:
这一步很重要,因为模型能不能正确调用工具,很大程度取决于:
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]:这个函数就是:
Ollama参数解释:
model_name
qwen3.5:0.8bmessages
tools
host
http://localhost:11434/api/chatpayload: dict[str, Any] = {
"model": model_name,
"stream": False,
"messages": messages,
}这里:
model
stream: False
messages
如果有工具,再补上:
if tools:
payload["tools"] = tools这一步是让模型知道:
request = urllib.request.Request(
host,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)逐项解释:
host
data=...
json.dumps(payload, ensure_ascii=False)
ensure_ascii=False 表示中文不要转成 \\uXXXX.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")表示:
utf-8 解码成字符串再用:
json.loads(...)把 JSON 字符串变回 Python 字典。
make_ollama_function_model(...)def make_ollama_function_model(model_name: str, host: str) -> FunctionModel:这是整份代码最有“agent 框架味道”的部分。
它的作用是:
PydanticAI 把本地 Ollama /api/chat 当成一个模型来用为什么需要它?
因为你本机这里的 Ollama:
/api/chat 是可用的/v1 的 OpenAI 兼容接口不稳定所以我们没有走最省事的 OpenAI provider,而是自己写了一个薄适配层。
run_ollama(...)def run_ollama(messages: list[ModelMessage], agent_info: AgentInfo) -> ModelResponse:这个函数就是:
PydanticAI 真正调用的“本地模型函数”它接收:
messages
agent_info
第一步:
response = call_ollama_chat(
model_name=model_name,
messages=build_ollama_messages(messages),
tools=build_ollama_tools(agent_info),
host=host,
)这是把所有前面写好的小函数串起来:
Ollamamessage = response.get("message", {})
parts: list[TextPart | ToolCallPart] = []这里:
response.get("message", {})
message然后准备一个 parts 列表,后面往里面放:
content = (message.get("content") or "").strip()
if content:
parts.append(TextPart(content=content))意思是:
TextPartTextPart 是 PydanticAI 认识的一种响应片段。
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 就会:
这就是 agent 工作流的关键闭环。
if not parts:
parts.append(TextPart(content="模型没有返回内容。"))这是一个防御性写法。
意思是:
虽然这不是理想情况,但它能帮助我们快速发现问题。
FunctionModelreturn FunctionModel(function=run_ollama, model_name=f"ollama:{model_name}")这一行的意思是:
run_ollama 这个本地函数PydanticAI 能识别的模型对象这样后面 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(
model=model,
system_prompt=(...),
output_type=str,
)这几个参数很重要:
model=modelsystem_prompt=(...)这里面写了几条关键规则:
analyze_wireless_link 和 suggest_modulation这说明:
output_type=str@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]:这个工具把一整段链路分析打包在一起。
为什么这样设计?
因为前面测试时发现:
所以这里故意把:
SNR合并成一次工具调用。
这也是工程里很真实的设计思路:
path_loss = 92.45 + 20 * math.log10(frequency_ghz) + 20 * math.log10(distance_km)这是一条自由空间路径损耗 FSPL 的常见公式。
这里:
92.45
math.log10(...)
20 * log10(f)
20 * log10(d)
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这里:
bandwidth_mhz * 1_000_000
MHz 转成 Hz-174
dBm/Hz10 * log10(bandwidth_hz)
noise_figure_db
SNRsnr_value = received - noise就是:
capacity_bps = bandwidth_hz * math.log2(1 + 10 ** (snr_value / 10))这是 Shannon 容量公式:
C = Blog2(1 + SNR)
这里注意:
snr_value 现在还是 dB10 ** (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),
}这里有两个值得注意的点:
round(..., 3)
capacity_bps / 1_000_000
bps 转成 Mbps所以这个工具最后返回的是一个字典,里面装着全部关键指标。
suggest_modulation(snr_db_value)def suggest_modulation(snr_db_value: float) -> str:这个工具比较简单:
SNR逻辑是 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--modelparser.add_argument(
"--model",
default="qwen3.5:0.8b",
help="Local Ollama model name, default: qwen3.5:0.8b",
)含义:
--modelqwen3.5:0.8b如果不写这个参数,就默认用这个模型。
--hostparser.add_argument(
"--host",
default="http://localhost:11434/api/chat",
help="Local Ollama chat endpoint, default: http://localhost:11434/api/chat",
)含义:
Ollama 接口地址11434 端口--promptparser.add_argument(
"--prompt",
default=DEFAULT_PROMPT,
help="User prompt for the communications agent",
)含义:
例如:
python3 0-pydanticai-comm-agent.py --prompt "请分析一个 5.8 GHz 的无线链路..."agent = build_agent(model_name=args.model, host=args.host)
result = agent.run_sync(args.prompt)这两行是主流程:
这里的:
run_sync(...)表示同步运行。
对初学者来说,可以先简单理解成:
except urllib.error.HTTPError as exc:这个表示:
比如:
except urllib.error.URLError as exc:这个表示:
比如:
Ollama 没启动except TimeoutError:表示:
这几段异常处理的意义是:
print(result.output)
return 0这里:
result.output
return 0
最后这句:
if __name__ == "__main__":
raise SystemExit(main())是 Python 脚本的标准写法,意思是:
main()这份代码最想让同学理解的,不是通信公式本身,而是 agent 的结构:
@agent.tool_plain 注册了Ollama + qwen3.5:0.8b这份脚本后面可以继续升级成:
这就是为什么这份脚本虽然小,但已经很像一个真正 agent 系统的雏形。