基于Ollama的本地AI代理:功能受限的工具、验证机制以及严格的步骤限制
Ollama 上的一个本地 AI 智能体,其特性包括:受限的工具列表、循环次数上限、Pydantic 结构化输出、明确的状态管理以及用于测试故障场景的测试集。
来源: I Built a Local AI Agent With Ollama — and the Hard Part Wasn’t the Model
这款本地发票处理工具的初始版本在大约五分钟内看起来相当不错。它的任务很简单:帮助小企业主追讨逾期未付的发票。该工具可以查询客户信息,找出未支付的发票,计算逾期天数,并起草提醒邮件。测试时要求它为Acme Plumbing查找逾期发票并准备一封友好的提醒函,它立刻就回复了,附上了发票内容、未付金额以及一篇格式精美的邮件。
问题在于其中部分信息是编造的。客户确实存在,但发票编号并不存在。金额看似合理,实则错误。该工具表面上做出了成功处理的样貌,实际上并未真正完成相关工作。
那一集讲述了构建智能体时最重要的教训:听起来聪明并不等同于可靠。只有当每个重要结论都能追溯到某个受控操作或可信来源时,智能体才是可靠的。接下来的内容将使用 Ollama 和 Python 重新构建发票处理智能体,加入明确的工具、验证机制以及简洁的智能体循环结构。推理在本地机器上完成,但这些原则几乎适用于所有智能体框架。
为何选择 Ollama
本地部署可以避免在开发过程中将客户数据发送给外部模型提供商,同时还能在不重新构建应用程序的情况下切换不同的开源模型。Ollama 通过简单的聊天 API 在本地提供模型服务,还支持调用工具,这样模型就可以请求函数执行,而无需编造不存在的答案。其部署流程非常简单:
ollama pull qwen3
pip install ollama
Python 发出的普通聊天请求如下所示:
from ollama import chat
response = chat(
model="qwen3",
messages=[
{"role": "user", "content": "Explain what an overdue invoice is."}
],
)print(response.message.content)
这样得到的只是聊天机器人,还不是智能体。
聊天机器人负责回答,智能体则按步骤执行操作
“智能体”这个词的使用较为随意。一个可行的定义是:在循环中运行的模型,能够使用各种工具,并拥有状态以及决定其下一步行动的规则。对于处理发票的任务,该循环流程如下:
- 用户提出请求。
- 模型判断是否需要使用工具。
- 应用程序验证工具使用请求。
- 应用程序执行相应的工具。
- 结果会被添加到对话记录中。
- 模型要么请求使用其他工具,要么给出最终答案。
该模型从不直接访问数据库,它只能请求应用程序调用开发者定义的工具。这一界限非常重要,因为语言模型擅长理解意图并生成文本,而应用程序代码则更擅长处理权限控制、数据查询、计算以及业务规则。
先从功能单一的工具开始
一个常见的初始错误是设计一个功能强大的单一工具,比如manage_invoices,它能实现搜索、更新、起草和发送功能。这不仅会增加调试难度,还会极大降低误用的风险。每个只承担单一任务的简单工具效果更好:
find_customer可将客户名称转换为真实的客户ID。get_overdue_invoices会返回对应ID的逾期账单。draft_reminder仅负责生成文本,不会发送任何内容。send_reminder会执行实际发送操作,并需要经过审批。
简化的本地数据层提供相关数据:
CUSTOMERS = {
"acme plumbing": {
"customer_id": "cus_1042",
"name": "Acme Plumbing",
"email": "billing@example.com",
}
}
INVOICES = [
{
"invoice_id": "INV-2048",
"customer_id": "cus_1042",
"amount": 1850.00,
"days_overdue": 18,
}
]
def find_customer(name: str) -> dict:
customer = CUSTOMERS.get(name.strip().lower())
return customer or {"error": "customer_not_found"}
def get_overdue_invoices(customer_id: str) -> dict:
matches = [
invoice
for invoice in INVOICES
if invoice["customer_id"] == customer_id
and invoice["days_overdue"] > 0
]
return {"invoices": matches, "count": len(matches)}
看看这些函数不会做什么。它们从不猜测。缺少客户时会产生明确的错误提示,而没有匹配的发票则会产生空列表。
真正的工具,而非想象中的访问方式
Ollama将Python函数视为工具,其名称、参数和文档字符串能为模型提供选择正确函数所需的信息。
import json
from ollama import chat
def find_customer(name: str) -> dict:
"""Find a customer by business name and return its verified record."""
customer = CUSTOMERS.get(name.strip().lower())
return customer or {"error": "customer_not_found"}
def get_overdue_invoices(customer_id: str) -> dict:
"""Return overdue invoices for a verified customer ID."""
matches = [
invoice
for invoice in INVOICES
if invoice["customer_id"] == customer_id
and invoice["days_overdue"] > 0
]
return {"invoices": matches, "count": len(matches)}
TOOLS = {
"find_customer": find_customer,
"get_overdue_invoices": get_overdue_invoices,
}
注册表词典不仅仅是一种便利工具,实际上它是一份允许列表。即便模型请求调用名为delete_customer的函数,应用程序也会拒绝,因为TOOLS中并不存在这样的工具。
智能体循环
正是这个循环将工具调用转化为智能体行为。它始于一个系统提示:
SYSTEM_PROMPT = """
You are an invoice assistant.
Rules:
- Never invent a customer, invoice, email address, balance, or date.
- Use find_customer before requesting invoices.
- Only use customer IDs returned by tools.
- If a tool returns an error or no records, explain that clearly.
- You may draft communication, but you cannot send it.
"""
def run_agent(user_request: str) -> str:
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_request},
] for _ in range(6):
response = chat(
model="qwen3",
messages=messages,
tools=list(TOOLS.values()),
) messages.append(response.message) if not response.message.tool_calls:
return response.message.content for call in response.message.tool_calls:
name = call.function.name
arguments = call.function.arguments if name not in TOOLS:
result = {"error": "tool_not_allowed"}
else:
try:
result = TOOLS[name](**arguments)
except (TypeError, ValueError) as error:
result = {
"error": "invalid_tool_arguments",
"detail": str(error),
} messages.append(
{
"role": "tool",
"tool_name": name,
"content": json.dumps(result),
}
) return "I stopped because the task exceeded the maximum number of steps."
将迭代次数限制在六次是有意为之,因为智能体绝不能无限循环地反复处理同一个问题。该循环还将工具的输出视为新的证据。模型首先解析“Acme Plumbing”这一名称,获取到cus_1042,之后才会针对该已验证的标识请求发票。
更好的提示语并非解决方案
幻觉现象会诱使开发者在系统提示语中再添加一句要求,比如要求智能体必须准确无误、绝不能编造内容。这样的指令虽有一定帮助,但并不能真正起到控制作用。可靠的解决方案来自结构上的调整:
- 数据库仍是真实数据的来源。
- 模型可以请求数据,但无法编造出看似有效的记录。
- 工具名称被列入允许列表,参数也会经过验证。
- 循环有明确的步数限制。
- 读取操作与写入操作被分开处理。
提示语会影响用户行为,而代码则负责强制执行这些规则。
边界处的结构化输出
自由格式的文本适合用于友好的邮件沟通,但当应用程序的其他部分需要处理这些内容时则存在风险。如果系统需要向用户返回包含审核界面、发票编号、总余额以及草稿的页面,Pydantic 模型可以用来定义相应的结构:
from pydantic import BaseModel, Field
class ReminderReview(BaseModel):
customer_name: str
invoice_ids: list[str]
total_due: float = Field(ge=0)
draft_subject: str
draft_body: str
requires_approval: bool = True
Ollama 通过其 format 参数接受 JSON 模式:
review_response = chat(
model="qwen3",
messages=messages,
format=ReminderReview.model_json_schema(),
)
review = ReminderReview.model_validate_json(
review_response.message.content
)
这能确保响应具有预期的结构。但它并不能证明这些值是正确的,因此在出现批准按钮之前,仍需将invoice_ids和total_due与实际工具的返回结果进行比对。模式验证用于检查结构是否正确,而业务验证则用于确认内容是否被允许且真实。生产环境中的系统需要两者兼备。
状态并非内存
长时间的对话会暴露另一个问题:模型会忘记选择了哪位客户,或者将旧发票混入新的请求中。其根源在于将整个聊天记录视为应用状态。虽然对话能提供有用的上下文,但关键状态应存储在明确的结构中:
task_state = {
"customer_id": "cus_1042",
"verified_invoice_ids": ["INV-2048"],
"approved_actions": [],
}
该模型可以总结事件,但应用程序拥有经过验证的标识符和审批权限。即便对话因上下文窗口限制而被截断,这些事实依然会保留。
上下文长度也会影响性能,因为更长的上下文会占用更多内存并增加处理时间。无需无限制地发送所有消息和数据库记录,只需传递当前决策所需的最少信息即可。
本地运行不等于安全
通过 Ollama 运行模型虽能让推理过程处于本地控制之下,但并不能保障应用程序的其他部分安全。智能体仍可能将数据泄露到日志中、调用不安全的内部接口、执行文档中隐藏的恶意指令,或拥有过高的权限。仍需遵循常规的后端安全原则:
- 仅授予每个工具其所需的权限。
- 避免在提示词和源代码中包含敏感信息。
- 将获取的文本视为不可信数据,绝不能当作系统指令处理。
- 记录每次工具调用及其结果、延迟和最终决策。
- 在数据进入模型上下文之前,先去除其中的敏感字段。
- 对于任何外部操作或不可逆的操作,都必须获得批准。
- 确保重要的写入操作具有幂等性,以避免重复执行。
模型绝不能成为安全边界。
测试
正常流程演示会掩盖大多数智能体故障,因此值得构建一个较小的测试集。测试案例包括:一名有逾期发票的已知客户和一名没有逾期发票的已知客户、与任何人都不匹配的姓名、两个相似的公司名称、尝试发送但跳过审批的情况、注入的绕过规则的指令、格式错误的工具参数以及超时的工具。针对每个案例,需记录预期的工具、允许的操作及最终结果,并在模型、系统提示或工具描述发生变化时重新运行整套测试。
最有用的衡量标准并非最终回复是否听起来不错,而是智能体是否按照正确的验证步骤顺序进行操作。
可用版本
经过重新设计后,原始请求会留下如下痕迹:
User request
→ find_customer(name="Acme Plumbing")
→ verified customer_id: cus_1042
→ get_overdue_invoices(customer_id="cus_1042")
→ verified invoice: INV-2048, $1,850, 18 days overdue
→ generate draft
→ wait for human approval
它没有第一个演示那么神奇,但却实用得多。一个并不存在的客户会让智能体暂停并请求进一步说明。没有过期的发票会被标记为逾期。未知的工具请求会被应用程序拒绝,而运行时间过长的循环也会安全停止。这些都不是什么光鲜亮丽的功能,但它们都在建立信任。
最后的教训
Ollama让运行功能强大的本地模型变得简单,但模型只是其中的一部分。真正的智能体是围绕它的整个系统:循环结构、工具契约、真实数据源、验证层、状态机、权限边界以及审批流程。
对于初学者代理,不必使用那十种工具或完整的应用访问权限。先从一个实际问题入手,搭配两个功能有限的只读工具,记录每一个决策,补充失败案例,等到只读流程已经极其可靠之后再赋予写入权限。关键的变化在于,不再纠结于如何让模型更具自主性,而是思考如何让每一步都可观察、可验证且安全。自主性适合用于演示,而受控的自主性才能打造出实用的产品。
相关阅读
- 本地编码代理工作流:Aider、Ollama、Git工作树与便捷回滚机制 —— 一种针对本地编码代理的受限工作流,通过Aider、Ollama、Git工作树、针对性测试以及简单的恢复路径,确保所有更改都可审查且可逆。