微软 AI Agent 入门课第 17 课:本地 Agent 不连云端怎么跑
有一类需求是绕不开的:代码在内网、文档不许出机器、或者干脆现场没网。这时候前面那些课里 await agent.run(...) 一行就把请求发到云端的写法,直接用不了。
ai-agents-for-beginners 的第 17 课就是冲着这个来的。它的目录是 17-creating-local-ai-agents/,动手部分只有一份 notebook:17-creating-local-ai-agents/code_samples/17-local-agent-foundry-local.ipynb,下文说「notebook」都是指它。README 开篇写明这一课要做的是让 Agent 在单机上推理、调工具、读文件、搜文档,全程不发一次云端推理请求。落到代码上,真正需要你改的地方比想象中少,但改的那几处每一处都值得说清楚。
前置条件:这一课比别的课多要几样东西
README 的 Prerequisites 一节把要求列得很直白,别跳过:
- 前置课程:第 4 课 Tool Use、第 5 课 Agentic RAG、第 11 课 Agentic Protocols / MCP、第 14 课 Microsoft Agent Framework。
- 硬件:README 自述「8 GB RAM is a realistic minimum」,16 GB 以上更宽裕;GPU 或 NPU 有帮助但不是必需。
- 运行时:需要装 Microsoft Foundry Local。
- Python 3.12+,仓库根目录的
requirements.txt,外加这一课单独要的foundry-local-sdk、openai、chromadb。
requirements.txt 里确实为这一课单列了一段注释 # Lesson 17 - Creating Local AI Agents with Foundry Local and Qwen,下面挂着 foundry-local-sdk 与 chromadb。notebook 的第一个代码格则是自己装:
%pip install foundry-local-sdk openai chromadb -q
Foundry Local 的安装,README 给的是这两行,注意 macOS 那行在原文里是被注释掉的:
# Install (example; follow the docs for your platform)
winget install Microsoft.FoundryLocal # Windows
# brew install microsoft/foundrylocal/foundrylocal # macOS
这里有个坑值得先说:README 正文写的是「see the documentation for your OS」,但仓库里实际给出的安装命令只有 Windows 的 winget 一条,macOS 那条还是注释状态,Linux 侧我们在这一课的文件里没有找到对应命令。也就是说,Windows 读者照抄就行,非 Windows 读者得去它链接的官方文档里自己找,仓库不负责这一段。
接下来是启动服务与查看状态,README 给的是这两条:
foundry model run qwen2.5-7b-instruct
foundry service status
qwen2.5-7b-instruct 是仓库示例里用的模型别名,notebook 里也是把它赋给 MODEL_ALIAS 这个常量的,换模型时改这一处即可。
接本地推理后端:真正变的只有 client 那几行
这一课的核心机制,README 用一句话概括:Foundry Local 对外暴露的是一个 OpenAI 兼容的 HTTP 端点,所以 OpenAI SDK 与 Agent Framework 的 OpenAI client 只需要换 base_url 就能对上。notebook 里对应的代码是:
from foundry_local import FoundryLocalManager
from openai import OpenAI
MODEL_ALIAS = "qwen2.5-7b-instruct"
# Foundry Local selects the best build for your hardware (CPU / GPU / NPU) automatically.
manager = FoundryLocalManager(MODEL_ALIAS)
model_info = manager.get_model_info(MODEL_ALIAS)
client = OpenAI(
base_url=manager.endpoint, # e.g. http://localhost:PORT/v1
api_key=manager.api_key, # local placeholder
)
MODEL_ID = model_info.id
有三个细节容易被一眼滑过去。
第一,端点不是你写死的。manager.endpoint 是 FoundryLocalManager 给出来的,README 明确说 notebook 用 SDK 自动发现端点,所以不用硬编码端口。你在别的地方看到的 http://localhost:PORT/v1 只是注释里的示意形式。
第二,api_key 也是从 manager.api_key 拿的,notebook 注释标它是 local placeholder。这跟云端那套凭据不是一回事,不要往里填真实密钥。
第三,喂给 client.chat.completions.create() 的 model 不是那个别名,而是 model_info.id——先 manager.get_model_info(MODEL_ALIAS),再取 .id。别名和实际 model id 在这套代码里是两个东西。
跟第 4 课云端示例比,代码差在哪
这是这一课最值得单独拎出来的部分。把 04-tool-use/code_samples/04-python-agent-framework.ipynb 和这一课的 notebook 摆在一起看,差异集中在三层。
一是 client 的来路完全不同。 第 4 课用的是 from agent_framework.foundry import FoundryChatClient,构造时要传 project_endpoint、model 和 credential=DefaultAzureCredential(),端点与部署名来自 AZURE_AI_PROJECT_ENDPOINT、AZURE_AI_MODEL_DEPLOYMENT_NAME 两个环境变量,缺了会主动抛 ValueError。第 17 课这边一个 Azure 相关的 import 都没有,凭据链路整条消失,取而代之的是 FoundryLocalManager + 裸的 OpenAI client。
二是工具注册方式换了。 第 4 课用的是 Agent Framework 的装饰器写法,函数上面挂 @tool(approval_mode="never_require"),参数用 Annotated[str, "..."] 写说明,然后 client.as_agent(name=..., instructions=..., tools=travel_tools) 把函数列表直接交给框架。第 17 课没有这一层:工具 schema 是手写的 TOOLS_SCHEMA,按 OpenAI tools 的 JSON 结构一条条列出 name、description、parameters;函数名到实现的映射也是手写的 TOOL_IMPL 字典。
三是循环得你自己转。 第 4 课是 await agent.run("...") 一行,框架内部把工具调用循环兜住了。第 17 课定义了 run_agent(user_query: str, max_iterations: int = 5),里面自己维护 messages 列表:判断 msg.tool_calls 是否为空,为空就返回 msg.content;不为空就把 assistant 的 tool-call 请求追加回去([tc.model_dump() for tc in msg.tool_calls]),再对每个 tc 取 tc.function.name、json.loads(tc.function.arguments or "{}"),本地执行后以 {"role": "tool", "tool_call_id": tc.id, "content": str(result)} 的形式喂回。循环跑满则返回 "Stopped: reached max tool-calling iterations."。
顺带一句:max_iterations 的默认值 5 是仓库当前代码里的默认值,随版本可能变动,别当成什么固定语义。
所以 README 那句「只改 base_url、其它代码不变」,对照下来更准确的说法是:跟云端 Agent 的心智模型不变,但这一课的示例代码是从 Agent Framework 降到了 OpenAI SDK 原生这一层。 你要是想保持第 14 课那套 Agent Framework 的写法去接本地端点,这一课的 notebook 里没有给出对应示例。
工具与本地 RAG:两处代码本身的细节
四个工具是 list_files、read_file、analyze_code、search_docs。前三个围着 PROJECT_ROOT 这个常量转,search_docs 走的是下面那个 Chroma collection,跟文件系统无关。涉及路径的那几个里,沙箱检查在 notebook 里被抽成了 _safe_path():
def _safe_path(path: str) -> Path | None:
"""Resolve a path and confirm it stays inside the project sandbox."""
full = (PROJECT_ROOT / path).resolve()
if full == PROJECT_ROOT or PROJECT_ROOT in full.parents:
return full
return None
值得注意的是,README 正文里贴的 read_file 片段没有这个辅助函数,判断是内联在 read_file 里的取反写法。语义一致,但你照 README 抄和照 notebook 抄会得到两份不同结构的代码——以 notebook 为准,README 那段是节选示意。
analyze_code 返回的是一段 JSON,里面 functions 的算法是 sum(1 for ln in lines if ln.strip().startswith("def "))。这是按行首 def 前缀数的,所以 async def 定义的函数在这个口径下不会被计入;todos 同理,按行里是否含 TODO 或 FIXME 计数。这是示例级别的度量,别当成静态分析。
本地 RAG 那段用的是 Chroma,代码是:
chroma_client = chromadb.Client()
collection = chroma_client.get_or_create_collection("project_docs")
collection.upsert(
ids=list(DOCS.keys()),
documents=list(DOCS.values()),
)
检索走 collection.query(query_texts=[query], n_results=2),取 results.get("documents", [[]])[0]。n_results=2 是仓库示例里的取值。
这里有一处源内不一致值得指出来:README 的示意图和正文都把 Chroma 描述成落在磁盘上的向量库(“Chroma vector DB - on disk”),而 notebook 实际用的是 chromadb.Client(),我们在这份 notebook 里没有找到指定持久化目录的代码。两处摆在一起看,README 讲的是这个模式该有的样子,notebook 给的是最省事的自包含跑法。要不要持久化,得你自己按 Chroma 的文档处理,这一课没写。
embedding 也留在本地——notebook 注释自述 Chroma 自带一个本地默认 embedding 模型,所以这一步不出机器。
边界:哪些是可选的、哪些仓库压根没写
MCP 那节是可选的。 notebook 的第 5 节标题就写着 Optional,代码从 os.getenv("LOCAL_MCP_COMMAND") 取命令,没设就直接打印跳过提示,整个 notebook 仍然能跑完。设了才会 import mcp 相关模块,用 StdioServerParameters(command=parts[0], args=parts[1:]) 起 stdio_client,进 ClientSession,await session.initialize() 之后 session.list_tools() 列出工具名。命令是靠 command.split() 拆的,所以带空格的路径在这个示例里会被拆坏。
本地 ≠ 安全。 README 和 notebook 都单独强调了这一点:本地 MCP server 是以你当前用户的权限跑的,能碰到你能碰的一切,要把它的作用域收到某个项目目录而不是整个家目录,并且把它的输出当作需要校验的输入。同样地,read_file 这类工具即便在本机也要做沙箱检查——这也是 _safe_path() 存在的原因。
不承诺的部分。 这一课的定位是 SLM 编排、工具干活,README 自述 SLM 在有边界的任务和工具调用上更合适,在开放式多跳推理和广博世界知识上更弱。它没有承诺任何具体的效果,我们也没有跑过这份 notebook,这里不谈速度、占用与回答质量。混合路由那张表(敏感或离线走本地、简单任务走本地、非敏感的深推理走云端、云端不可用时回落本地)是 README 给的设计取向,不是代码里实现好的调度器——仓库里没有找到这一课对应的路由实现。
怎么验证接对了
按 notebook 的顺序,有三个自然的检查点:
- 服务通没通。 先
foundry service status;然后跑连接那格,notebook 会打印Connected to Foundry Local. Serving: {MODEL_ID}与Endpoint: {manager.endpoint}。能打印出 endpoint,说明 SDK 发现端点这一步是成的。 - 推理通没通。 notebook 紧接着有一格纯 chat completion 的 sanity check,不带任何工具。这一格能出内容,说明
MODEL_ID与base_url配对了。 - 工具调用通没通。 工具那几格自己就带独立验证:定义完工具直接
print(list_files()),建完索引直接print(search_docs("how are passwords handled?"))。这两句不经过模型,是纯 Python 的自检。真正验证 Agent 链路的是后面那三问——读文件的、走 RAG 的、走analyze_code的各一问,分别落在不同工具上。
把这个顺序倒过来看就是排障顺序:第 3 步不对但第 2 步对,问题在 schema 或循环,跟本地端点无关;第 2 步就不对,回去看 manager.endpoint 与 model_info.id。
最后提醒一句:这一课涉及本机安装运行时、在本机执行外部进程(MCP server 走 stdio)、以及让模型驱动的工具读你的文件。这些动作的权限边界要按你自己的环境评估。
本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。