如果你只是想给自己的网页包一层大模型 API,那做个聊天框只要半天。但“AgentChat”这个词一旦出现,事情就不一样了——它意味着用户用自然语言下达任务,系统不只负责应答,还要自己去判断该调用什么工具、执行什么动作、把结果拿回来再组织成回答。我最初上手做这个小项目时,最大的困惑不是不会调 API,而是不知道“调度循环”应该怎么写:到底是谁在决定要不要搜网页?工具调用结果又该怎么塞回对话历史?这篇文章就把我从零搭建一个最小可用 AgentChat 的完整过程拆开讲,适合已经有基本 Python 后端经验、但对 Agent 机制还停留在概念层的开发者。你会看到一个能跑的骨架,而不是那种只演示一次就扔的玩具代码。
1. AgentChat 不是“套壳聊天框”:先分清需求层级
1.1 普通问答和 Agent 式对话差在哪
先说一个容易被忽略的事实:普通 ChatBot 的代码结构通常是 用户输入 -> 调模型 -> 返回文本,从头到尾只有一个“模型对外发言”的环节。而一个 Agent 式对话最少要多出两个环节:判断是否需要工具,以及把工具结果交还给模型做下一步推理。
这两个环节对应的就是 Agent 领域常说的 ReAct 思路:模型先思考(Reason),再行动(Act),观察工具返回结果(Observe),然后继续思考。我不建议你在第一版就去实现完整的复杂 Agent 框架,比如多智能体协作、记忆向量化、自动规划任务树,那些是后面的事情。第一版最该关注的核心循环是:
- 接收用户消息。
- 把它和上下文一起交给大模型,并且告诉模型当前可用的工具清单。
- 模型返回两种可能之一:直接给出最终回答,或者申请调用某个工具并附带参数。
- 如果是工具调用,系统执行对应函数,把结果以 tool 消息形式追加到对话里。
- 回到第 2 步继续,直到模型给出最终回答或达到轮数上限。
这个循环一跑通,你才算真正拥有了一个 AgentChat 的核心骨架,后面接什么工具都只是往注册表里塞函数的事。
1.2 这个项目适合谁、会用到哪些核心概念
我写这套东西时定位得很清楚:它不是给生产环境设计的重型框架,而是一个让人能在一晚上看懂、并在第二天继续扩展的调试原型。如果你是想学习 Agent 工作原理的后端开发者,或者需要一个内部工具聊天入口的独立开发者,按本文搭建就很合适。
在这个项目里你会实际接触下面几个关键概念:
- Function Calling / Tools 协议:让大模型输出结构化工具调用请求,而不是让模型自己瞎写调用文本。
- 工具注册与自描述:每个工具用 JSON Schema 描述自己能干什么、参数是什么,模型据此决定是否调用。
- 消息队列的 Role 管理:至少 4 种角色,system、user、assistant、tool,缺一个都会导致请求报错或上下文错乱。
- 流式输出:Agent 执行过程中可能有多次内部工具调用,不能让用户干等,要用 SSE 把阶段性状态和最终回答推给前端。
1.3 项目目录与依赖设计
我习惯先把目录结构定下来,因为它能帮你把“聊天逻辑”和“工具执行”解耦清楚。下面是我在实际项目中用的最小结构:
code复制agentchat/
├── main.py # FastAPI 入口
├── .env # 模型地址、密钥、工具参数等配置
├── agent/
│ ├── __init__.py
│ ├── llm.py # 模型接入与流式封装
│ ├── executor.py # Agent 调度循环
│ └── session.py # 会话历史管理
├── tools/
│ ├── __init__.py
│ ├── registry.py # 工具注册中心
│ ├── calculator.py # 计算器工具
│ ├── current_time.py # 当前时间工具
│ ├── web_fetch.py # 网页抓取工具
│ └── web_search.py # 搜索工具
└── static/
└── index.html # 极简调试前端
依赖方面我只装了这几个:fastapi、uvicorn、python-dotenv、openai、requests、beautifulsoup4。装好后用 uvicorn main:app --reload 就能把服务拉起来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 模型接入先行:先跑通一条不带工具的直通管道
2.1 为什么要用 OpenAI 兼容协议来封装模型
很多模型服务商现在都提供 OpenAI 兼容接口,这是一个非常省事的约定。你不需要为每家服务商各写一套 SDK 调用逻辑,只要把 base_url、api_key、model 三个值放到环境变量里,代码主体完全不用改。我当时选型时也对比过是不是直接上各家原生 SDK,后来发现兼容协议的好处是:今天用这家,明天换那家,只改配置不碰代码。对于 AgentChat 这种强依赖模型“工具调用”能力的场景,这个迁移成本优势特别重要。
实际你可以通过修改 .env 来切换不同的模型服务,甚至本地部署一些支持工具调用的开源模型,只要它提供 OpenAI 兼容端点。
2.2 封装一个可复用的 LLM 调用模块
在 agent/llm.py 里,我做了一层薄封装。它支持两件事:普通对话和携带工具定义的对话。
python复制import os
from openai import OpenAI
client = OpenAI(
base_url=os.getenv("LLM_BASE_URL"),
api_key=os.getenv("LLM_API_KEY"),
timeout=60,
)
def call_llm(messages, tools=None):
kwargs = {
"model": os.getenv("LLM_MODEL"),
"messages": messages,
"temperature": 0.2,
}
if tools:
kwargs["tools"] = tools
kwargs["tool_choice"] = "auto"
resp = client.chat.completions.create(**kwargs)
return resp.choices[0].message
这里有几个细节值得留意。
第一个是 temperature,我没有用默认的 1.0,而是调到 0.2。Agent 场景和闲聊场景不一样,工具调用需要的是稳定和准确,不是你偶尔蹦出一些创意。温度太高会让模型在“是否该调用工具”这个判断上飘忽不定。
第二个是 tool_choice="auto",它表示让模型自己判断是否调用工具。你可以强制 "required" 让模型每次都必须调用工具,但那样做会让“不需要工具的问题”也被强行套进工具流程,实际体验很差。auto 是正确默认值。
第三个是超时,我设置成 60 秒,因为 Agent 循环本身可能要跑好几轮,如果单次请求无限等待,整个会话会被卡死。这个在实际使用中很快会踩到。
2.3 第一轮验证:直接向模型提问
在配好 .env 之后,我建议先用一段小脚本验证链路通不通,不要急着接工具。
python复制from agent.llm import call_llm
resp = call_llm([
{"role": "system", "content": "你是一个中文助手。"},
{"role": "user", "content": "你好,请简单介绍一下你自己。"},
])
print(resp.content)
如果你能看到正常的中文回复,说明模型、密钥、网络三个环节都没问题,接下来可以进入真正的 Agent 部分。
3. 工具注册中心:让系统随时知道“现在有哪些能力”
3.1 为什么工具必须带“自描述”而不是硬编码映射
最朴素的 Agent 实现是维护一个 if-else:如果模型说“查天气”,就去调天气 API。但大模型不是按固定指令走的,它不会说出一个精确的函数名给你匹配。模型看到的是一堆工具描述,然后它自己决定是否调用、传什么参数。所以你提供给模型的 tools 参数必须是一个结构化的 JSON Schema 列表,里面要写清楚工具名字、功能、参数类型、参数含义。
这就是工具自描述的意义:工具层把自己能做什么“翻译”给模型听,模型用结构化输出“翻译”回来。 如果你把工具描述写得含糊,模型就会经常用错参数。
3.2 BaseTool 基类与注册中心
我给每个工具定义了一个极简基类:
python复制from typing import Any, Dict
class BaseTool:
name: str = ""
description: str = ""
parameters: Dict[str, Any] = {}
def run(self, **kwargs) -> str:
raise NotImplementedError
def schema(self) -> Dict[str, Any]:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
},
}
注册中心则维护了一张“工具名 -> 工具实例”的表:
python复制class ToolRegistry:
def __init__(self):
self._tools = {}
def register(self, tool: BaseTool):
self._tools[tool.name] = tool
def get(self, name: str):
tool = self._tools.get(name)
if not tool:
raise ValueError(f"工具 {name} 不存在")
return tool
def all_schemas(self):
return [t.schema() for t in self._tools.values()]
有人可能会问,为什么不直接用一个 dict 当注册表?因为后面每个工具还要有 run 方法和 schema 方法,用一个类的实例统一管理更清晰。而且如果你之后要做工具权限控制,比如某些工具只有特定角色能调用,在注册中心统一拦截是最方便的。
3.3 JSON Schema 怎么写才不容易被模型误用
举个例子,一个“获取当前时间”的工具,你要把 description 写到“参数为空,不需要任何参数”,跟写“获取当前时间”相比,误导概率会小很多。参数里的每个字段都要尽量描述清楚:
code复制{
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词,建议使用具体、简短的关键词,而不是完整问句"
}
},
"required": ["query"]
}
在工具运行的返回内容里,我也强烈建议返回干净、结构化、适合 LLM 阅读的文本,而不是原始对象。比如计算结果工具返回 "528",不要返回一个 Python 对象;抓网页工具返回正文截断的纯文本,不要返回一个 HTML 文档。
3.4 我把哪几个工具作为起步配置
第一版我实现了四个工具,覆盖了最常见的 Agent 演示场景:
| 工具名 | 作用 | 依赖 |
|---|---|---|
| calculator | 计算数学表达式 | 无 |
| current_time | 获取当前日期时间 | 无 |
| web_fetch | 抓取一个网页的标题和正文 | requests + bs4 |
| web_search | 根据关键词搜索并返回摘要列表 | 可配置搜索服务 |
没有一开始就接“发邮件”“操作数据库”这类需要凭据管理、又容易出安全事故的工具。等 Agent 循环稳定后再逐步扩展,是比较稳妥的节奏。
4. 让模型调度工具:Agent 闭环最关键的一步
4.1 大模型“决定调用工具”的底层机制
很多人第一次看到 tools 参数时会以为:模型内部真的执行了函数。其实它没有。模型只是根据对话上下文,在输出时选择走一条特殊的结构化输出路径:返回一个 tool_calls 字段,里面包含函数名和参数 JSON 字符串。真正执行函数的是你的 Python 代码。也就是说,大模型是一个“决策者”,你的调度器才是“执行者”。
这个区分非常重要,因为它解释了一堆问题:为什么工具返回后必须重新发给模型?因为模型原本不知道真实结果,它只是猜测“这时候应该查一下”,查完的结果必须作为新消息告诉它,它才能继续组织最终回答。
4.2 executor.py 里的调度主循环
我在 agent/executor.py 里实现了一个支持流式输出的 run_agent_stream 生成器。它会不断调用模型,遇到工具调用就执行,然后继续循环,直到模型给出最终回答,或超过最大轮数。
python复制import json
from agent.llm import call_llm_stream
from tools import registry
SYSTEM_PROMPT = (
"你是一个通过工具帮用户完成任务的AI助手。\n"
"当用户的问题涉及实时信息、计算、网页内容时,请先调用合适的工具。\n"
"调用工具后,你需要根据工具返回结果组织最终回答。\n"
"如果工具执行失败,请如实告诉用户失败原因,不要编造结果。\n"
)
def build_messages(user_text, history):
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
messages.extend(history[-20:])
messages.append({"role": "user", "content": user_text})
return messages
def run_agent_stream(user_text, history, max_rounds=6):
messages = build_messages(user_text, history)
tools = registry.all_schemas()
for _ in range(max_rounds):
# 调用模型,同时收集增量文本和工具调用片段
content_buf = []
tool_calls_buf = {}
stream = call_llm_stream(messages, tools)
for chunk in stream:
if not chunk.choices:
continue
delta = chunk.choices[0].delta
if delta.content:
content_buf.append(delta.content)
yield {"type": "delta", "content": delta.content}
if delta.tool_calls:
for tc in delta.tool_calls:
idx = tc.index
tool_calls_buf.setdefault(idx, {
"id": "", "name": "", "arguments": ""
})
if tc.id:
tool_calls_buf[idx]["id"] = tc.id
if tc.function:
if tc.function.name:
tool_calls_buf[idx]["name"] += tc.function.name
if tc.function.arguments:
tool_calls_buf[idx]["arguments"] += tc.function.arguments
text = "".join(content_buf)
# 组装本轮 assistant 消息
assistant_msg = {"role": "assistant", "content": text or None}
if tool_calls_buf:
assistant_msg["tool_calls"] = []
for idx in sorted(tool_calls_buf.keys()):
tc = tool_calls_buf[idx]
assistant_msg["tool_calls"].append({
"id": tc["id"],
"type": "function",
"function": {
"name": tc["name"],
"arguments": tc["arguments"] or "{}",
},
})
messages.append(assistant_msg)
# 没有工具调用:这就是最终回答
if not tool_calls_buf:
return text
# 执行工具并追加 tool 消息
for tc in assistant_msg["tool_calls"]:
fn_name = tc["function"]["name"]
fn_args = json.loads(tc["function"]["arguments"] or "{}")
yield {"type": "tool", "content": f"正在调用工具:{fn_name}({fn_args})"}
try:
tool = registry.get(fn_name)
result = tool.run(**fn_args)
if len(result) > 1000:
result = result[:1000] + "\n[结果过长,已截断]"
except Exception as exc:
result = f"工具执行失败:{exc}"
messages.append({
"role": "tool",
"tool_call_id": tc["id"],
"content": result,
})
return "抱歉,步骤太多还没有得到最终结论,请简化问题或更换问法。"
注意这段代码里,我每次 yield 了两种事件类型:delta 是模型正常输出,tool 是系统内部正在调用工具。前端可以据此区分流式文本和工具状态提示。
4.3 为什么必须先 append assistant 消息再执行工具
OpenAI 兼容协议有一个硬性要求:tool 角色的消息必须携带 tool_call_id,并且它前面必须存在对应的 assistant tool_calls 消息。如果漏掉 assistant 消息,直接塞一条 tool 消息,接口会报错。
这个细节害我踩过一次坑。起初我天真地以为“反正工具结果就是要给模型看”,直接在 messages 里追加 {"role": "tool", ...} 就够了。结果模型服务端直接返回 400。后来看文档才明白,服务端要用 tool_call_id 把工具结果和之前的调用请求关联起来,形成一条完整的调用链。所以调整后的顺序永远是:
- 把带 tool_calls 的 assistant 消息追加进消息队列。
- 依次执行每个工具调用。
- 每执行完一个工具,就把结果作为 role=tool 的消息追加进去。
- 带着更新后的消息队列重新调模型。
4.4 循环边界的兜底方案
Agent 循环中最让人头疼的是“模型反复调用同一个工具”或“工具链越长越偏题”,所以我加了两个兜底:
max_rounds=6,无论是否成功,超过这个轮数就强制终止。- 单条工具结果截断到 1000 字,防止一个超长网页把后续所有上下文窗口占满。
轮数的选择不是越大越好。工具调用链过长时,中间的错乱也更容易累积;而且每一轮都消耗 token 和时间。实际使用中,大部分任务在三轮以内就能完成,6 轮已经足够宽松。
5. 工具实现细节:安全执行、网页抓取和骨架式搜索
5.1 计算器:不要用裸 eval,用 AST 白名单
计算器是最直观的 Agent 演示工具,但如果直接 eval("用户输入"),风险极大。比如用户输入 __import__('os').system('rm -rf /'),eval 会在你服务器上执行任意代码,这是绝对不能接受的。
我采用的方案是使用 Python 的 ast 模块做白名单校验,只允许四则运算、括号和基础数字/运算符节点,其他一律拒绝:
python复制import ast
import operator
class CalculatorTool(BaseTool):
name = "calculator"
description = "计算数学表达式,支持 + - * / 和括号。输入一个不包含等号的纯数学表达式。"
parameters = {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "例如:((128*36)+3840)/16"
}
},
"required": ["expression"]
}
_operators = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
}
def run(self, expression: str) -> str:
tree = ast.parse(expression, mode="eval")
def eval_node(node):
if isinstance(node, ast.Expression):
return eval_node(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in self._operators:
left = eval_node(node.left)
right = eval_node(node.right)
return self._operators[type(node.op)](left, right)
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
operand = eval_node(node.operand)
return operand if isinstance(node.op, ast.UAdd) else -operand
raise ValueError("表达式中包含不允许的语法")
try:
result = eval_node(tree)
if abs(result) > 1e15:
return "数值过大,仅支持计算绝对值不超过 1e15 的表达式"
return str(result)
except Exception as exc:
return f"表达式无效:{exc}"
这里的关键是只允许 Constant 和算术运算符,因此像字符串拼接、函数调用、属性访问等语法都会被拒绝。这个工具演示了 Agent 安全设计的一个重要原则:工具能接收的外部输入是攻击面,必须做最小化处理。
5.2 current_time:最简单的工具也不要忽视时区
获取当前时间看似最简单,但如果你不考虑时区,模型给出的答案可能错得离谱。服务器默认时区和用户时区不一致时,同一个“现在几点了”的问题会得到完全不同的答案。
我是这样实现的:
python复制from datetime import datetime, timezone, timedelta
class CurrentTimeTool(BaseTool):
name = "current_time"
description = "获取当前日期和北京时间,不需要任何参数。"
parameters = {"type": "object", "properties": {}}
def run(self) -> str:
now = datetime.now(timezone(timedelta(hours=8)))
return now.strftime("%Y-%m-%d %H:%M:%S %A")
把时区固定写清楚,既方便模型理解返回内容,也避免了服务器时区漂移导致的时间误导。
5.3 网页抓取:正则提取标题和段落文本
如果一个 Agent 连网页内容都读不了,它能处理的问题会很有限。web_fetch 工具我用 requests 抓 HTML,再用 BeautifulSoup 提取主要文字:
python复制import requests
from bs4 import BeautifulSoup
class WebFetchTool(BaseTool):
name = "web_fetch"
description = "抓取一个网页地址,返回该网页的标题和正文文字。"
parameters = {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "以 http:// 或 https:// 开头的完整网页地址"
}
},
"required": ["url"]
}
def run(self, url: str) -> str:
if not url.startswith(("http://", "https://")):
return "URL 必须以 http:// 或 https:// 开头"
resp = requests.get(url, timeout=15, headers={
"User-Agent": "Mozilla/5.0 (compatible; AgentChat/1.0)"
})
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
title = soup.title.get_text(strip=True) if soup.title else ""
for tag in soup(["script", "style", "nav", "footer"]):
tag.decompose()
paras = []
for p in soup.find_all(["p", "h1", "h2", "h3", "li"]):
text = p.get_text(" ", strip=True)
if text:
paras.append(text)
body = "\n".join(paras)
if len(body) > 2000:
body = body[:2000] + "\n[正文过长,已截断]"
return f"标题:{title}\n正文:\n{body}"
执行前必须检查 URL 前缀,避免用户让工具去请求 file:///etc/passwd 这类本地文件。另外一个隐患是 SSRF:如果这个服务对公网开放,任何人都能诱导它去抓内网地址。所以我在实际使用时,只把 web_fetch 暴露在内部网络或加了访问令牌的环境中,绝不在没有鉴权的公网服务里放开它。
5.4 web_search:我把外呼抽象成可插拔端点
搜索工具是个典型的两难问题:不接搜索 API,Agent 的实时性很弱;接了搜索 API,又需要用户准备密钥。我最后的做法是把搜索端点做成可配置,同时在本地保留一个极简的演示实现。
python复制import os
import requests
class WebSearchTool(BaseTool):
name = "web_search"
description = "搜索互联网上的最新信息,返回前几条结果的标题、链接和摘要。"
parameters = {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索关键词,尽量简短具体"
}
},
"required": ["query"]
}
def run(self, query: str) -> str:
endpoint = os.getenv("SEARCH_ENDPOINT", "")
api_key = os.getenv("SEARCH_API_KEY", "")
if not endpoint:
return "搜索服务未配置,无法执行搜索"
resp = requests.get(endpoint, params={"q": query}, headers={
"Authorization": f"Bearer {api_key}",
}, timeout=15)
resp.raise_for_status()
data = resp.json()
lines = []
for item in data.get("items", [])[:5]:
title = item.get("title", "")
link = item.get("link", "")
snippet = item.get("snippet", "")
lines.append(f"- {title}\n {link}\n {snippet}")
return "\n\n".join(lines) if lines else "没有搜索到结果"
把搜索服务抽象成 endpoint 的好处是,你可以在内网用 ElasticSearch、向量数据库后端甚至公司内部 Wiki 搜索来替代公网搜索,完全不用改 Agent 代码。这比把某一个具体服务商写死要灵活得多。
6. 上下文管理是 AgentChat 的隐形骨架
6.1 消息队列里的 role 必须各司其职
很多人在 Agent 上下文管理上翻车,是因为不理解 system、user、assistant、tool 四种消息分别承载什么。我打个比方:system 是岗位职责说明,user 是客户需求,assistant 是你的工作记录,tool 是你查询回来的资料单据。如果模型看到的工作记录里混入了“资料单据”,它就无法区分哪些话是自己说的、哪些是外部系统返回的。
尤其是当你把一条 tool 消息直接放在 user 消息的位置上传给模型,模型大概率会把工具返回当作新的用户指令去执行,这就可能导致它编造一个并不存在的执行结果。因此我在 append 消息时始终严格遵守服务端要求的顺序,并给每条 tool 消息都配上对应的 tool_call_id。
6.2 系统提示词如何引导工具调用的边界
系统提示词不宜写太长,但要把“工具调用边界”说清楚。我见过不少人在这里踩误区:把工具描述写得极其详细,把系统提示词写成一个百科全书,结果模型面对新问题时更容易偏离。实际上,模型已经在 tools 参数里看到了每个工具的 description,系统提示词只需要做好两件事:
- 告诉模型什么时候应该调用工具。
- 告诉模型工具失败时不要编造结果。
我上面的 SYSTEM_PROMPT 就是这个思路。它没有规定工具的使用细节,只是设定了整体行为边界。真正让模型做出正确选择的,是靠每个工具自己的 description 写得好不好。
6.3 历史会话裁剪与长文本保护
Agent 和历史记录叠加后,上下文增长比普通聊天快得多。因为每一轮用户问题都可能带出多条工具消息。如果不限制 messages 的长度,很快会把模型窗口打满。我在这里用了两个简单的保护:
- 只保留最近 20 条历史消息。
- 工具返回结果超过 1000 字就截断。
这两条虽然在极限场景下会丢信息,但对一个演示原型来说足够。如果你后续要应对更长的会话,可以再引入摘要压缩:每 N 轮把历史消息用模型总结成一段摘要,以 system 消息身份放回上下文。
7. 前端对话页和 SSE 流式输出
7.1 为什么选 SSE 而不是 WebSocket
Agent 执行过程中可能需要好几轮工具调用,总耗时可能达到十几秒。如果让用户一直盯着空白页面,体验很差。我选择 SSE(Server-Sent Events)而不是 WebSocket,原因很简单:我的数据流是单向的,服务端往客户端推,客户端不需要持续往服务端发消息。
WebSocket 适合双向频繁通信,比如在线协作编辑、游戏,但它对 AgentChat 来说有点重。SSE 基于普通 HTTP,天然支持断线重连,代码也更轻量。FastAPI 的 StreamingResponse 可以直接把生成器函数变成 SSE 响应流。
7.2 FastAPI 后端怎么组织会话和事件流
后端入口我写得很薄。它只做三件事:读取请求中的 sessionId 和用户消息、从会话存储中取出历史、把 run_agent_stream 的生成器包装成 SSE 返回。
python复制import json
from uuid import uuid4
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from agent.executor import run_agent_stream
app = FastAPI()
app.mount("/", StaticFiles(directory="static", html=True), name="static")
# 演示用内存存储,生产请替换为 Redis
sessions = {}
class ChatRequest(BaseModel):
session_id: str = ""
content: str = ""
class ResetRequest(BaseModel):
session_id: str
@app.post("/chat")
def chat(req: ChatRequest):
if not req.session_id:
req.session_id = uuid4().hex
history = sessions.setdefault(req.session_id, [])
def event_generator():
final_text = ""
for event in run_agent_stream(req.content, history):
if event["type"] == "delta":
final_text += event["content"]
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
# 只在最终完成后保存可见对话,中间工具消息不写入历史
history.append({"role": "user", "content": req.content})
history.append({"role": "assistant", "content": final_text})
yield f"data: {json.dumps({'type': 'done'}, ensure_ascii=False)}\n\n"
return StreamingResponse(event_generator(), media_type="text/event-stream")
@app.post("/reset")
def reset(req: ResetRequest):
sessions.pop(req.session_id, None)
return {"ok": True}
这里有一个很关键的取舍:我没有把工具中间消息写入跨轮次保存的 history。 如果写了,下一轮用户提问时,上一轮那条超长搜索结果会再次占据上下文。用户真正关心的是上一轮最终的答复内容,而不是中间过程。历史里只保留 user 和最终 assistant 消息,既简洁又省 token。
7.3 极简 HTML 原生调试界面
前端我不建议一开始就上 Vue 或 React,先用原生 HTML 把调试界面搭起来最快。下面这段代码只依赖浏览器原生的 fetch 和 ReadableStream:
html复制<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>AgentChat 调试台</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; }
#messages { border: 1px solid #ddd; border-radius: 8px; padding: 16px; min-height: 300px; }
.tool-msg { color: #888; font-size: 13px; margin: 4px 0; }
.user-msg { color: #111; font-weight: 600; margin-top: 12px; }
#input { width: 100%; padding: 10px; margin-top: 12px; box-sizing: border-box; }
#send { margin-top: 8px; padding: 8px 16px; }
</style>
</head>
<body>
<h1>AgentChat 调试台</h1>
<div id="messages"></div>
<input id="input" placeholder="例如:帮我计算 (128*36 + 3840)/16,然后搜索一下最近的相关讨论">
<button id="send">发送</button>
<script>
const sessionId = Math.random().toString(36).slice(2);
const messagesEl = document.getElementById("messages");
const inputEl = document.getElementById("input");
const sendBtn = document.getElementById("send");
function appendText(text, cls) {
const div = document.createElement("div");
div.className = cls || "";
div.textContent = text;
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
async function send() {
const content = inputEl.value.trim();
if (!content) return;
inputEl.value = "";
appendText("用户:" + content, "user-msg");
const resp = await fetch("/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, content })
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let assistantDiv = document.createElement("div");
messagesEl.appendChild(assistantDiv);
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split("\n\n");
buffer = blocks.pop();
for (const block of blocks) {
if (!block.startsWith("data:")) continue;
const payload = JSON.parse(block.slice(5).trim());
if (payload.type === "delta") {
assistantDiv.textContent += payload.content;
messagesEl.scrollTop = messagesEl.scrollHeight;
} else if (payload.type === "tool") {
appendText(payload.content, "tool-msg");
}
}
}
}
sendBtn.addEventListener("click", send);
inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter") send(); });
</script>
</body>
</html>
这个页面里,payload.type === "delta" 的内容会不断追加到 AI 回答区域,payload.type === "tool" 的内容则以灰色小字显示在对话中间。用户可以看到“正在调用工具:web_search({'query': ...})”这种提示,从而理解 Agent 正在做什么,而不是怀疑系统卡死了。
7.4 并发会话隔离和最小鉴权
最后给 FastAPI 后台做两个务实的处理。
会话隔离用 sessionId 就够了。每个 session 有自己的历史列表,互不串场。我上面的演示代码用的是内存字典,单进程测试没有问题,想长期跑建议换成 Redis 并设置过期时间。
鉴权要视部署环境而定。内部调试可以不鉴权,但如果你的 AgentChat 要暴露到公网,必须加访问令牌。我的做法是在前端请求头里带一个 Authorization 字段,FastAPI 里写一个最简单的依赖函数统一校验。这样能挡住绝大多数扫描流量,避免工具层被外界胡乱调用。
8. 实测表现与最容易翻车的五个边界
8.1 一组真实会话记录与判读
我用完整代码跑了几轮测试。第一类问题是纯计算,效果最好:
code复制用户:帮我算 (128*36 + 3840) / 16 等于多少?
Agent:
正在调用工具:calculator({'expression': '(128*36+3840)/16'})
结果是 528。
模型收到用户问题后,没有直接心算,而是正确选择了计算器工具,这符合我在系统提示词里的设定:关于数值计算的问题,优先调用工具。这类任务只要函数名和参数 Schema 写得准,成功率非常高。
第二类是实时信息查询。我配置了一个搜索服务后提问“搜索一下最近三个月 AI 编程助手有什么新变化”。模型把 query 精简成“AI编程助手 近年发展”,调用搜索工具,拿到返回的五条摘要后
