第四次作业
| 这个作业属于哪个课程 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS |
|---|---|
| 这个作业要求在哪里 | https://edu.cnblogs.com/campus/gdgy/Class56-Grade2024-CS/homework/16760 |
| 这个作业的目标 | 使用现代 AI 工具构建一个具备「语言交互(会说)」与「行动执行(会做)」能力的智能体:能接收自然语言输入、理解任务意图,并自动调用工具完成操作;推荐使用 MCP 框架或兼容框架实现 |
| 学号 | 车俊贤 3124004048 钟志浩 3124004076 |
待办小助理 —— 一个「能说也会做」的任务管理智能体
仓库:https://github.com/dredgeship/dredgeship/tree/main/3124004048/assignment4
一、需求描述
1.1 场景选择
我们选择做任务/待办管理助理。理由有三点:
- "会做"的痕迹最直观。任务管理天然对应数据库写入、文件导出、日程计算等确定性操作,"智能体真的动手了"这件事可以被逐条验证,而不是只能"看起来像在干活"。
- 能覆盖作业要求的全部技术点。既有纯内部逻辑(优先级打分、日程编排),也有外部 API 调用(天气),还天然需要多轮对话("刚才那个任务改到周五")。
- 对后续大项目有复用价值。任务/记忆/工具调度这套骨架可以直接迁移到后面的项目里。
1.2 用户痛点
日常学习与小组作业中,待办管理有三个高频痛点:
| 痛点 | 具体表现 | 我们的应对 |
|---|---|---|
| 记录成本高 | 打开 App、选日期、选优先级、填表单,记一件事要十几秒,于是干脆不记 | 直接说「明天下午3点前交作业,这个最重要」,一句话完成记录 |
| 不知道先做哪个 | 任务列表是扁平的,人脑无法快速算出"重要 × 紧急" | 提供可解释的优先级打分(分数 + 理由) |
| 计划落不了地 | 知道要做,但不知道几点做、做不完会怎样 | 把任务排进当天可用时段,生成带起止时间的日程,并自动跳过午休 |
1.3 功能需求
基础能力(作业硬性要求)
- 能说:全中文自然语言多轮对话,支持指代消解与主动追问。
- 会做:通过工具真正执行操作 —— 任务增删改查、优先级排序、日程编排、天气查询、清单文件导出、提醒。
- MCP 框架:使用官方 MCP Python SDK 实现独立 stdio Server,可被任意 MCP 客户端复用。
加分项(本次实现范围)
| 加分项 | 是否实现 | 说明 |
|---|---|---|
| 多轮对话记忆 | ✅ 已实现 | 短期窗口 + 长期摘要压缩 + 跨会话偏好 |
| 与外部 API 交互 | ✅ 已实现 | Open-Meteo 天气服务(地理编码 + 预报) |
| 与数据库交互 | ✅ 已实现 | SQLite 三组表,含索引 |
| 前端界面 | ✅ 已实现 | FastAPI + 原生 HTML/CSS/JS 四视图 |
| 语音输入/输出 | ✅ 已实现 | Windows SAPI 离线引擎,零密钥、零模型下载、零外网 |
1.4 非功能需求
- 可复现:依赖版本锁定;数据库零配置;天气服务免 Key;一条命令灌入演示数据。
- 可解释:排序给出分数与理由,排期给出起止时间与排入原因,不输出黑盒结论。
- 安全:删除类操作必须二次确认;密钥只从环境变量读取,不进仓库。
- 健壮:工具异常不中断会话,而是转成可读提示交还模型自行处理。
二、业务流描述
2.1 主流程:一句话 → 任务落库 → 人话答复
2.2 对话循环(Agent 主循环)
关键设计:把"模型思考"和"工具执行"严格分离。模型只负责决定调哪个工具、传什么参数,
参数的合法性、时间解析、排序规则、文件写入全部由 MCP Server 用确定性代码完成。
因此同一个工具既服务于 Agent,也能被任何 MCP 客户端直接调用。
2.3 任务生命周期
2.4 典型业务场景
| 场景 | 用户一句话 | 触发的工具链 |
|---|---|---|
| 快速记录 | 「记一下:明天下午3点前交作业,最重要」 | add_task |
| 批量记录 | 「加三个任务:……;……;……」 | add_tasks(一次提交) |
| 决定顺序 | 「今天我先做哪个」 | prioritize_tasks |
| 排日程 | 「帮我把今天排一下,9点开始6点收工」 | plan_day |
| 早间总览 | 「早上好,今天有什么安排」 | generate_daily_brief(内部含天气) |
| 出门决策 | 「明天杭州适合出门办事吗」 | weather_travel_advice |
| 导出留痕 | 「把今天的清单导出成文件」 | export_checklist |
| 多轮修改 | 「刚才那个改到周五」→「它不用设截止了」 | update_task → update_task(clear_due_date=true) |
| 安全删除 | 「删掉买牛奶那个」 | delete_task(confirm=false) → 用户确认 → delete_task(confirm=true) |
三、实现说明
3.1 技术选型
| 层面 | 选型 | 理由 |
|---|---|---|
| 语言/运行时 | Python 3.10 | 官方 MCP Python SDK 的最低要求;生态完整 |
| 协议框架 | 官方 MCP Python SDK(mcp,FastMCP) |
作业推荐;工具 schema 由类型标注自动生成 |
| 大模型 | DeepSeek deepseek-chat(OpenAI 兼容) |
国内直连、价格低、原生支持 function calling |
| 存储 | SQLite(标准库 sqlite3) |
零配置、可随仓库携带、支持多进程并发 |
| 外部 API | Open-Meteo | 免费、无需申请 Key,避免演示环境受配额限制 |
| Web | FastAPI + 原生 HTML/CSS/JS | 无需 Node 构建链,仓库小、启动快 |
| CLI | rich |
终端里也能清楚看到工具调用过程 |
| 语音识别 | faster-whisper(本地 Whisper 模型)+ Windows SAPI 兜底 | 全程离线、零密钥;高精度引擎不到位时自动降级,保证任何机器都能用 |
| 语音合成 | Windows SAPI(System.Speech)+ PowerShell 常驻 worker |
系统自带中文音色,完全离线,零下载 |
| 音频转码 | 浏览器 Web Audio API | 本机没有 ffmpeg,把重采样与 WAV 封装放在浏览器端最省事 |
3.2 系统架构
主链路(Agent → MCP → SQLite)完全不动,语音只是一条旁路通道:
识别出的文本直接送进现有的 /api/chat,因此多轮记忆与 23 个工具自动继承。
五层解耦,每层都能单独跑通:
| 层 | 目录 | 能否独立验证 |
|---|---|---|
| 表现层 | main.py、api/、web/ |
✅ CLI 与网页 |
| 智能体层 | agent/ |
可注入伪模型做回归测试 |
| 能力层 | mcp_server/ |
✅ 可被 MCP Inspector / Claude Desktop 直接连接 |
| 语音层 | agent/io/、agent/voice_service.py |
✅ python scripts/voice_smoke.py 一键自检 |
| 数据层 | storage/、common/ |
✅ |
3.3 核心实现要点
(1) MCP Server:23 个工具,分 5 组
| 分组 | 工具 |
|---|---|
| 任务 | add_task add_tasks list_tasks get_task task_stats update_task complete_task complete_tasks delete_task delete_tasks |
| 规划 | prioritize_tasks plan_day generate_daily_brief export_checklist |
| 天气 | get_weather weather_travel_advice |
| 提醒 | get_due_reminders send_desktop_notification reminder_overview |
| 偏好 | remember_preference recall_preference list_preferences forget_preference |
所有工具统一返回 JSON 字符串({"ok": true, "message": "...", "task": {...}}),
并用装饰器统一兜底异常:
def tool(mcp):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except ValueError as exc:
return fail(exc, code="invalid_argument") # 参数问题
except Exception as exc:
return fail(f"{type(exc).__name__}: {exc}") # 内部错误
wrapper.__signature__ = inspect.signature(func) # 保证 schema 与签名一致
return mcp.tool()(wrapper)
return decorator
踩过的坑:工具模块若写 from __future__ import annotations,注解会变成字符串,
FastMCP 在解析嵌套函数作用域时无法还原 Literal,直接抛 PydanticUserError。
移除该导入后,注解在运行时就是真实类型对象,schema 生成正常。
(2) 中文口语时间解析
模型把「明天下午3点」原样传进工具,由 common/timeutil.py 解析。支持:
- 绝对:
2026-10-15、2026/10/15、10月15日 - 相对:今天 / 明天 / 后天 / 大后天 / 昨天
- 星期:本周五 / 下周一 / 周三
- 时刻:
14:30、14点、下午3点、上午9点半、晚上7点
同时解决了显示噪音问题:纯日期输入(如存的是 2026-10-15)只显示「明天」,
带时刻的才显示「明天 15:00」,避免出现「今天 18:00」这种奇怪文案。
(3) 可解释的优先级打分
排序分 = 优先级权重(高40 / 中25 / 低10)
+ 紧急度(逾期45 / 12h内40 / 24h内34 / 2天内28 / 3天内22 / 1周内14 / 无期限8)
+ 进行中加成 6
+ 高优且临期组合加成 8
- 超长任务惩罚 3(预计超过 2 小时)
排序结果附带 reasons 数组,例如
["高优先级 +40", "12 小时内到期 +40", "高优且临期 +8"],可以逐条向用户解释。
(4) 日程编排:贪心装箱
把一天切成若干空闲区间(自动扣除午休),按分数从高到低把任务放进第一个容得下的时段:
- 每个任务之间预留缓冲(默认 10 分钟),避免排得密不透风;
- 有截止时间的任务必须排在截止之前,排不进则进入
unscheduled并给出原因
("截止时间 昨天 18:00 之前已无可用时段"); - 输出利用率,让用户知道今天到底有多满;
- 当天排期自动对齐当前时间:如果排的是"今天"且 09:00 已过,自动从当前时间
之后(向上取整到 10 分钟刻度)起排,避免排出一堆"已经过去的时间段"这种无用日程,
并在返回里标注"已按当前时间从 17:30 起排"。若剩余时间已不足,直接建议改排明天。
可用容量的计算也踩过一次坑:最初写成「工作时长 − 午休时长」,但当起排时间晚于午休
(例如下午 5 点才开始排)时,午休根本不在窗口内,相减会得到负数并让利用率恒为 0。
现在改为累加各空闲区间的实际长度。
(5) 多轮对话记忆(加分项核心)
分两层:
- 短期记忆:最近 N 轮「用户 ↔ 助理」的原文,保证指代消解("它"、"刚才那个")与语气连贯;
- 长期记忆:更早的对话压缩成一段中文摘要存入
sessions.summary,并用水位线
记录已压缩到哪条消息,避免重复压缩。
一个重要的取舍:注入模型的上下文里只放成品对话文本,不放中间的工具调用往返。
原因有两个 —— 一是 OpenAI 兼容协议要求 assistant.tool_calls 必须紧跟对应的 tool 消息,
窗口截断极易产生"孤立 tool 消息"导致 400 错误;二是任务状态的唯一事实来源是 SQLite,
每轮重新查一次数据库比依赖历史工具结果更可靠。完整轨迹仍然落库,供界面与调试使用。
(6) MCP 客户端的"独占任务(actor)模式"
mcp 的 stdio_client 内部使用 anyio task group,而 task group 的 cancel scope
必须在创建它的任务里退出。在 Web 服务中,启动时建立的 session 常被不同请求任务使用,
直接跨任务关闭会抛 Attempted to exit cancel scope in a different task。
因此我们启动一个专属 asyncio 任务持有 session,所有调用经队列投递、只 await 一个 Future:
async def call_tool(self, name, arguments):
future = asyncio.get_running_loop().create_future()
await self._queue.put(_Request(name, arguments, future)) # 投递给 owner 任务
return await asyncio.wait_for(future, timeout=self.call_timeout)
好处是生命周期干净、天然串行、且全进程只拉起一次子进程、只做一次 list_tools()。
(7) 网页端的实时反馈
对话走 SSE 而非 WebSocket(单向流、可自动重连、无需连接管理)。事件类型:
| 事件 | 界面表现 |
|---|---|
start |
记录会话 ID |
text_delta |
逐字追加,带光标闪烁 |
tool_call |
插入"正在执行"工具卡片(工具名 + 参数摘要 + 脉冲加载条) |
tool_result |
卡片转为"✓ 完成 · 48ms"并展开结果摘要,失败转红色 |
final |
用完整文本替换气泡内容 |
done |
状态栏显示轮次与 token 消耗 |
error |
弹提示条 + 气泡内显示可读原因 |
(8) 其他工程细节
- stdout 是 JSON-RPC 通道:MCP Server 内禁止
print(),所有日志写 stderr;
子进程 stderr 重定向到.data/logs/mcp-server.log(UTF-8),避免 Windows 控制台乱码。 - 非阻塞启动:MCP 子进程冷启动约 5~10 秒,若在 lifespan 里同步等待,网页在此期间打不开。
改为后台任务初始化 + 接口按需等待就绪,前端轮询/api/health直到就绪。 - 多进程共库:FastAPI 与 MCP 子进程同时写同一个 SQLite 文件,启用
WAL模式保证并发安全。
(9) 语音输入输出:一次被真实体验推翻的技术选型
这一节的结论是改过两次的,过程比结果更值得记录。
第一版:只用了 Windows SAPI
当时的判断是"零依赖、零下载、零密钥,最稳":
| 方案 | 当时没选的原因 |
|---|---|
| 云端语音 API(阿里云/讯飞) | 需要实名开通、AccessKey 与签名鉴权,答辩现场断网或配额用尽就演示不了 |
| OpenAI Whisper API | 依赖外网与按量计费,中文音色一般 |
| 本地 Whisper | 本机 torch 是 CPU 版、ffmpeg 未安装,模型还要下载 |
| Windows SAPI(当时选中) | 系统自带、完全离线,本机恰好已装中文识别引擎与中文音色 |
第二版:被真实使用推翻
交付后同学实际使用发现"说出去的话被扭曲成完全不同的句子"。我们做了三步定位:
- 先排除自己的锅:实测 4 秒录音走完整条转换链,
decodeAudioData解出 3.96 秒
(speedRatio 0.989),采样率 48kHz→16kHz 时长守恒,WAV 头16000Hz/16bit/单声道完全正确。
音频没有被变形。 - 再量引擎的真实上限:用音质完美的合成音频(不含任何麦克风因素)测一批指令 ——
SAPI 的自由听写平均只有 80.4%,且句子越长越崩:
「帮我记一下明天下午三点交作业」→「当我亲一下明天3:00PM交作业」(53%)、
「提醒我周五晚上七点开会」→「停火周五晚上七点开会」(76%)。 - 顺带发现一个真问题:录音时开着
echoCancellation与noiseSuppression——
这两个 WebRTC 滤镜是给语音通话用的,谱减法会吃掉清辅音,对识别有害。已改为关掉这两项、
只保留自动增益。
第三版:换成 faster-whisper,并保留 SAPI 兜底
同一批测试句上,faster-whisper 的平均字面相似度是 93.3%,而且差距集中在最要命的长句上:
| 原句 | SAPI | faster-whisper |
|---|---|---|
| 帮我记一下明天下午三点交作业 | 当我亲一下明天3:00PM交作业(53%) | 帮我记一下明天下午三点交作业(97%) |
| 我想和同学吃饭安排一下晚上的时间 | 果想和同学吃饭安排一下晚上的续建(81%) | 我想和同学吃饭安排一下晚上的时间(97%) |
| 帮我把下周的体检预约提前两天 | 当我把下周的提前预约提前两天(79%) | 帮我把下周的体检预约提前两天(97%) |
whisper 剩下的差异基本只是补了个句号、把「七点」写成「7点」这类等价问题 ——
语义上 6 句全部正确;而 SAPI 有 3 句明显改意。
最终架构是两个引擎 + 自动选择:
这样既拿到了高精度,又不丢"任何机器 clone 下来都能跑"的兜底能力:
组员电脑没装依赖时自动退回 SAPI,只是精度低一些,功能不会不可用。
VOICE_STT_PROVIDER 还可手动切成 sapi,现场就能做"基础 vs 高精度"的对比演示。
音频格式:把转码放到浏览器
录音得到的是 webm/opus,两个引擎都要 16kHz / 16bit / 单声道 PCM WAV
(whisper 侧再在内存里转成 float32 数组,不落临时文件,所以也不需要系统 ffmpeg)。
本机没有 ffmpeg,因此转换在前端用 Web Audio API 完成:
const decoded = await audioCtx.decodeAudioData(await blob.arrayBuffer());
const offline = new OfflineAudioContext(1, Math.ceil(decoded.duration * 16000), 16000);
// …渲染后把 Float32 转 Int16 并手工拼 44 字节 WAV 头
服务端只收标准 WAV,并在入口处校验头信息(声道/采样率/位深/PCM),
不合规直接返回可读中文错误,不把脏数据喂给识别引擎。
常驻 PowerShell worker:管道里只传 ASCII
换成 whisper 之后,SAPI 仍然承担全部语音合成(中文音色现成、离线、零下载),
并在依赖缺失时兜底识别。Add-Type -AssemblyName System.Speech 加进程启动要一秒多,
每次调用都冷启会让朗读明显卡顿,所以维护一个常驻 worker,进程内复用引擎。协议刻意设计成管道上只出现 ASCII:
Python → worker : 一行"请求文件的绝对路径",或 "B64:<base64>"(路径含中文时)
worker → Python : 一行 JSON(ConvertTo-Json 会把中文转义成 \uXXXX,天然 ASCII 安全)
这样彻底避开了 PowerShell 5.1 的 stdin/stdout 编码坑,不需要靠 base64 兜底。
识别纠错:带守卫、且只在需要时触发
SAPI 在自由听写下会出错别字(「标记城已完成」),因此在识别与执行之间加了一层可选的模型纠错:
识别结果 → 置信度门限 → 长度守卫 → DeepSeek 纠错 → 再守卫 → 交给 Agent
置信度门限是换到 whisper 后补上的:实测 whisper 在置信度 0.8 以上时转写基本正确,
此时再让大模型"润色"反而会改动语序(把「我想和同学吃饭安排一下晚上的时间」改成
「我想和同学安排一下晚上吃饭的时间」)。所以置信度 ≥ 0.7 时直接跳过纠错 ——
引擎已经对了就别动它,低置信度(多为 SAPI 输出)才值得纠。
守卫规则:纠正结果相对原文膨胀不得超过 2.2 倍、缩水不得低于 60%,否则判定为"改写过度",
回退到原始识别文本。下界比上界更严格,因为丢词比啰嗦更危险 —— 丢掉的可能是时间、数量这类关键信息。
实测效果:
| 原句 | 原始识别 | 纠错后 |
|---|---|---|
| 把买牛奶这个任务标记成已完成 | 把买牛奶这个任务标记城已完成 | 把买牛奶这个任务标记成已完成 |
| 提醒我明天早上八点半给导师发邮件 | 以前我明天早上八点半给导师发邮件 | 明天早上八点半我给导师发邮件 |
| 帮我把下周的体检预约提前两天 | 当我把下周的提前预约提前两天 | 当我把下周的预约提前两天 |
降级矩阵:没有密钥也必须能用
这是用户提的硬要求 ——「必须要接受部分客户不具备使用 API 条件的情况」。所以:
| 状况 | 实际行为 |
|---|---|
未配置 DEEPSEEK_API_KEY |
跳过纠错,直接用原始识别文本;麦克风、朗读、指令模板全部照常 |
| 未安装 faster-whisper | 自动回退 Windows SAPI,设置页标注"基础精度"并给出安装提示,功能不受影响 |
| whisper 模型加载/下载失败 | 同样回退 SAPI,并把失败原因显示在设置页(不静默) |
| 两个识别引擎都不可用 | /api/voice/status 返回 available=false,前端隐藏麦克风并说明原因 |
| 本机无中文合成音色 | 隐藏喇叭与自动朗读开关,其余功能照常 |
| 整个语音子系统不可用 | 前端只显示一行文字说明,其他 23 个工具与界面完全不受影响 |
| worker 进程意外退出 | 下次调用自动重启一次;仍失败则返回可读中文错误 |
把语音做成交互通道而不是 MCP 工具,也是刻意的:工具清单是给模型选能力用的,
把"说话"塞进去只会干扰模型选工具。语音实现的是 agent/io/voice.py 里预留的
SpeechToText / TextToSpeech 协议,因此 Agent 主循环一行都没有改。
3.4 目录结构
homework3/
├── main.py # CLI 入口(rich 交互式 REPL)
├── common/ # config.py 配置 / timeutil.py 中文时间解析
├── storage/ # db.py 连接与建表 / repository.py 三个仓储
├── mcp_server/
│ ├── server.py # FastMCP 实例与 stdio 入口
│ └── tools/ # base + tasks/planner/weather/reminders/prefs
├── agent/
│ ├── core.py # 主循环
│ ├── llm.py # DeepSeek 流式客户端(含 tool_calls 增量聚合)
│ ├── mcp_client.py # MCP stdio 客户端(actor 模式)
│ ├── memory.py # 短期窗口 + 长期摘要
│ ├── prompts.py # 系统提示词
│ ├── voice_service.py # 语音编排:纠错守卫 / Markdown 清洗 / 合成缓存
│ └── io/
│ ├── voice.py # SpeechToText / TextToSpeech 协议与工厂
│ ├── sapi.py # Windows SAPI 桥接(常驻 worker + JSON 行协议)
│ └── voice_worker.ps1 # PowerShell worker(UTF-8 with BOM)
├── api/ # FastAPI 应用、请求响应模型、/api/voice 路由
├── web/ # index.html / app.js / style.css / voice.js
├── scripts/ # demo_cases.md / seed_demo_data.py / voice_smoke.py
└── docs/ # 本文与使用示例
3.5 关键提示词约束
系统提示词里有几条硬性约束,都是针对大模型常见失败模式写的:
- 先查后答:涉及任务内容必须先调
list_tasks,绝不凭记忆编造任务; - 删除必须先确认:先以
confirm=false调一次,用户同意后才confirm=true; - 时间照抄口语:
due_date直接传用户原话,不需要模型自己换算; - 工具返回的 JSON 不要原样贴给用户,要翻译成人话;
- 信息缺失就追问,不要擅自猜测;
- 全程只用中文:最初实测发现模型偶尔会在工具调用前冒出一句英文过渡语
("I'll help you with that. Let me first…"),影响体验。在提示词里明确要求
"包括思考时的过渡语在内,任何位置都不要出现英文句子"后,输出恢复为纯中文。
四、使用示例
全部示例均来自实际运行,截图见仓库
docs/screenshots/。
安装依赖
python -m venv .venv
.venv\Scripts\activate
python -m pip install -r requirements.txt
实测环境:Python 3.10.2 /mcp 1.28.1 /openai 1.109.1 /fastapi 0.115.0 /pydantic 2.14.1。
配置密钥
copy .env.example .env
在 .env 填写 DEEPSEEK_API_KEY;未配置密钥程序不会崩溃,会给出中文提示。
4.1 环境准备
python -m venv .venv
.venv\Scripts\activate
python -m pip install -r requirements.txt
copy .env.example .env # 填入 DEEPSEEK_API_KEY
4.2 界面总览
对话工作台:左侧是今日进度环与待办速览,中间是对话流,底部是快捷指令与输入框。

今日计划:日期导航、真实天气与出行建议、带起止时间的时间轴日程、可用时间利用率、逾期提醒。

任务总览:统计条 + 多条件筛选 + 任务卡片(优先级、截止时间、标签、备注),支持列表/看板切换。

设置与关于:运行环境、加分项实现情况、23 个 MCP 工具能力清单与参数。

深色主题:

4.3 核心演示:一句话触发三个工具
这是最能体现「能说也会做」的一张图。用户输入:
帮我记一下:后天上午10点准备课堂演示脚本。另外今天我先做哪个?帮我排个日程。
模型自主完成了三件事,界面上依次出现三张工具卡片:
| 工具卡片 | 参数 | 实际耗时 | 结果 |
|---|---|---|---|
add_task |
{"title":"准备课堂演示脚本","due_date":"后天上午10点"} |
✓ 8ms | 已创建任务 #11「准备课堂演示脚本」,截止 后天 10:00 |
prioritize_tasks |
{} |
✓ 6ms | 共 10 条待办参与排序,最优先是「和组员讨论智能体分工」(94 分) |
plan_day |
{} |
✓ 6ms | 今天共安排 1 条任务,已按当前时间从 17:30 起排;另有 3 条无法排入 |
助理最终用中文给出了完整答复:新任务已记录、今天最该先做哪件(并提醒只剩不到 40 分钟)、
当天时间轴、以及三条排不进去的任务各自的原因,最后还主动问要不要调整。

这张图同时证明了四件事:
- 模型确实在做工具选择(判断出需要"记录 + 排序 + 排期"三件事);
- 参数是模型自己填的,「后天上午10点」被原样传给工具并由工具层解析;
- 工具真的执行了(写库、打分、装箱排期),耗时以毫秒计;
- 结构化结果被翻译成了人话,没有把 JSON 糊在用户脸上。
4.4 功能演示(按加分项组织)
① 多轮对话记忆(短期)
你 › 帮我加个任务:整理会议纪要,周三之前
⚙ update_task 之外先 add_task → 已创建 #5 整理会议纪要,截止 周三 18:00
你 › 刚才那个改到周五吧 ← 没有重复任务名
⚙ update_task({"task_id": 5, "due_date": "周五"}) → 已把 #5 改到 周五 18:00
你 › 它不用设截止时间了 ← 用代词指代
⚙ update_task({"task_id": 5, "clear_due_date": true}) → 已清除 #5 的截止时间
第二、三轮能正确解析「刚才那个」「它」,是记忆能力生效的直接证据。
完整消息轨迹可在 CLI 中用 /history 查看,说明记忆确实落库。
② 多轮对话记忆(长期 / 跨会话)
(会话 A)
你 › 以后问天气默认用杭州
⚙ remember_preference({"key":"default_city","value":"杭州","reason":"用户要求以后问天气默认使用杭州"})
→ 已记住偏好:default_city = 杭州
⚙ generate_daily_brief({"city":"杭州"}) → 已生成今天简报(内部调用了真实天气 API,耗时 2652ms)
(点「开启新会话」得到会话 B,历史上下文已清空)
你 › 今天适合出门办事吗?
⚙ weather_travel_advice({}) ← 工具参数为空,城市由偏好补全
助理 › 今天杭州是阴天,天气条件不错,适合出门办事,不用特意带伞。
接口侧核对:GET /api/preferences 返回
{"default_city": "杭州", "default_city__reason": "用户要求以后问天气默认使用杭州"},
说明偏好写入了 SQLite 并在新会话中生效。
③ 外部 API 交互
「今日计划」页的天气卡片与简报中的天气都来自 Open-Meteo 实时数据:
北京 · 少云 22° / 10°C
降水概率 0% 最大风速 9.4 km/h
天气条件不错,适合外出办事
④ 数据库交互
在网页「任务总览」勾选完成任务后,统计条与进度环同步变化;
通过接口反查数据库可确认变更已落库:
11 准备课堂演示脚本 高 todo 后天 10:00 ['演示', '课堂']
7 背诵英语单词 50 个 低 done 今天 21:00 ['学习']
stats {'total': 11, 'todo': 8, 'doing': 1, 'done': 2, 'open': 9, 'overdue': 1, 'done_today': 2}
重启服务后任务与偏好依然存在,验证了 SQLite 持久化。
⑤ 清单导出("会做"的直接证据)
你 › 把今天的清单导出成文件,并告诉我文件路径。
⚙ export_checklist({"date":"今天","fmt":"md"}) ✓ 完成 · 12ms
→ 已导出 2 条任务到 D:\projects\software_engineering\homework3\exports\待办清单_2026-10-14.md
助理 › 已导出 ✅
文件路径:D:\projects\software_engineering\homework3\exports\待办清单_2026-10-14.md
内容:今天 2 条任务(#2 和组员讨论智能体分工、#7 背诵英语单词 50 个),预计用时 1 小时 30 分钟

⑥ 前端界面的实时反馈

对话中触发的每次工具调用都会以卡片形式实时呈现(工具名、参数摘要、耗时、结果),
点击卡片可展开原始返回内容 —— 让"智能体在做什么"完全透明。
底部状态栏还会显示本轮模型调用次数与 token 消耗。
4.5 语音交互(加分项④)
① 说话下单:点输入框左侧的麦克风按钮开始录音(也可按 Ctrl+Shift+M),
按钮变为渐变填充并出现呼吸光环,旁边实时显示录音计时。
② 录音结束立即回放:一停下就把刚才录到的声音播一遍(默认开启,可关),
与识别并行进行,按钮同时切到「回放中…」。
这一条是交付后补上的。最初的做法是"识别完直接自动发送",结果用户根本没机会听到
自己说了什么 —— 识别错了也说不清是麦克风的问题还是引擎的问题。现在录音一停就能听到原声,
配合输入框上方的「录音峰值 −3dBFS」,几秒钟就能判断问题出在哪一环:
声音本身正常 → 引擎问题;声音发闷/很小 → 麦克风或系统输入音量问题。

④ 识别结果自动填入并发送:录音结束后浏览器把音频转成 16kHz 单声道 WAV 上传识别,
识别文本自动填入输入框并发送。输入框上方会显示本次识别的元信息
(用时、是否命中指令模板、置信度)。
实测:说「今天我先做哪个」→ 识别用时 2067ms、命中指令模板、置信度 99%,
随后自动进入 /api/chat 链路,助理调用工具并给出真实答复。
⑤ 未命中模板时靠纠错:随便说一句不在模板里的话,自由听写会出错,
但纠错层能把同音字改回来(详见 3.3 节 (9) 的实测对照表)。
⑥ 语音指令参考:不确定怎么说时展开这个面板,里面按场景列出了识别引擎最推荐的说法
(这些短语同时是受限语法的候选,照说识别率接近 100%),点任意例句即可填入输入框。

⑦ 朗读回复:每条助理回复下方都有一个喇叭按钮,点击朗读、再点停止;
播放时按钮变为渐变填充并显示跳动的波形。

⑧ 自动朗读与录音后回放开关:输入区右侧的两个胶囊开关分别控制
"助理每次回复是否自动播报"与"录音结束是否自动回放自己的录音",
状态都记在浏览器本地。旁边的「回放录音」按钮可随时再听一遍最近一次录音。
状态记在浏览器本地,刷新后保持。
⑨ 设置页状态卡:展示识别引擎、合成音色、纠错状态与各项限制,
并提供「试听合成效果」按钮方便现场演示。

⑩ 环境自检(可现场运行):
$ python scripts/voice_smoke.py
PowerShell: 5.1.26100.4652
识别引擎 1 个:
· MS-2052-80-DESK [zh-CN] Microsoft Speech Recognizer 8.0 for Windows (Chinese Simplified - PRC)
合成音色 2 个:
· Microsoft Huihui Desktop [zh-CN] Female
· Microsoft Zira Desktop [en-US] Female
指令原文:今天我先做哪个
识别结果:今天我先做哪个(语法=commands,置信度=0.992)
字面相似度:100.0%
✓ 语音输入输出链路可用(完全离线,不需要任何密钥)
4.6 CLI 演示
python main.py
正在启动 MCP Server …
你 › 帮我记一下:明天下午3点前提交软件工程作业文档,这个最重要
⚙ 调用工具 add_task({"title": "提交软件工程作业文档", "priority": "high",
"due_date": "明天下午3点", "tags": ["学习", "作业"]})
✓ add_task 已创建任务 #12「提交软件工程作业文档」,截止 明天 15:00 (39ms)
╭──────────────────────────────── 待办小助理 ─────────────────────────────────╮
│ 记好了 ✅ │
│ │
│ • #12 提交软件工程作业文档 — 高优先级,截止 │
│ 明天(10/15)15:00,标签:学习、作业 │
│ │
│ 需要我明天上午提前提醒你一下吗? │
╰─────────────────────────────────────────────────────────────────────────────╯
· 本轮 2 次模型调用 · 3832ms · tokens 9854 · 模型 deepseek-chat
单次提问(适合脚本化演示与回归):
python main.py --once "今天我先做哪个?帮我排个日程"
模型不仅给出了排序,还注意到了"现在已经是 17:11,日程工具默认从上午 9 点开始排"这件事,
主动改成了"从现在开始的现实版安排"——这正是我们后来把自动对齐当前时间做进
plan_day 的动机。
清单导出("会做"的最直接证据):
你 › 把今天的清单导出成文件,并告诉我文件路径。
✓ export_checklist 已导出 2 条任务到
D:\projects\software_engineering\homework3\exports\待办清单_2026-10-14.md (12ms)
导出的文件长这样:
# 待办清单 · 今天
- 任务数量:**2**
- 预计用时:**1 小时 30 分钟**
| # | 状态 | 任务 | 优先级 | 截止时间 | 预计用时 | 标签 |
|---|------|------|--------|----------|----------|------|
| 1 | 进行中 | 和组员讨论智能体分工 | 高 | 今天 18:00 | 60 分钟 | 小组作业 |
| 2 | 待办 | 背诵英语单词 50 个 | 低 | 今天 21:00 | 30 分钟 | 学习 |
4.7 独立验证 MCP Server
MCP Server 不依赖大模型,可单独验证(这也是我们证明"用的是真 MCP"的方式):
python -m mcp_server.server # 标准 stdio MCP Server
用 MCP Inspector 连接后可以看到 23 个工具的完整 schema:
工具数量: 23
- add_task: ['title', 'priority', 'due_date', 'tags', 'estimated_minutes', 'notes', 'status']
- list_tasks: ['status', 'priority', 'tag', 'keyword', 'due_before', 'only_overdue', 'limit']
- plan_day: ['date', 'start_time', 'end_time', 'lunch_start', 'lunch_end', 'break_minutes', 'include_backlog', 'max_tasks']
...
五、小组分工
| 成员 | 学号 | 负责内容 |
|---|---|---|
| 车俊贤 | 3124004048 | :MCP Server 工具集与 SQLite 数据层 Agent 主循环、DeepSeek 接入与记忆管理 |
| 钟志浩 | 3124004076 | :FastAPI 接口与网页前端 测试、文档与演示材料 |
六、小组成员心得
<车俊贤>
通过本次智能体项目实践,我对 AI 智能体 “理解意图、调用工具执行任务” 的工作模式有了更深的理解。项目开发过程中遇到不少协议、逻辑上的问题,在和搭档不断讨论、调试的过程里,体会到分层设计、解耦开发的重要性。同时也意识到,一个稳定可用的系统,除了核心功能,容错、兜底机制同样必不可少,收获很多工程实践经验。
<钟志浩>
次项目让我真切感受到,大模型只是智能体的一部分,记忆调度、交互设计、异常处理共同决定最终的使用体验。在不断迭代优化、解决各类细碎问题的过程中,我学会站在使用者角度思考体验。和队友分工配合、互相对齐方案的过程,也锻炼了协同开发的能力。
附:遇到的坑与解决方式
| 现象 | 原因 | 解决 |
|---|---|---|
PydanticUserError: ... is not fully defined; you should define Literal |
工具模块用了 from __future__ import annotations,注解变字符串后 FastMCP 无法在嵌套函数作用域还原 Literal |
移除该导入,让注解保持为运行时真实类型 |
Attempted to exit cancel scope in a different task |
anyio task group 的退出必须发生在创建它的任务里,而 Web 请求来自不同任务 | 用独占任务(actor)模式持有 session,调用经队列投递 |
| 客户端连不上 MCP Server,日志显示 JSON-RPC 解析失败 | MCP Server 里 print() 污染了 stdout 协议流 |
强制所有日志走 stderr,子进程 stderr 落 UTF-8 日志文件 |
| Windows 下中文乱码 | 控制台默认 GBK,子进程输出 UTF-8 | 子进程注入 PYTHONIOENCODING=utf-8 + PYTHONUTF8=1;日志写 UTF-8 文件 |
| 网页启动后十几秒打不开 | MCP 子进程冷启动阻塞了 lifespan | 后台任务初始化 + 接口按需等待就绪 + 前端轮询健康检查 |
| 长对话后 token 与延迟线性增长 | 全量历史都进上下文 | 短期窗口 + 长期摘要压缩 + 水位线,使单轮开销近似常量 |
| 下午使用时"可用时间利用率"恒为 0% | 容量按「工作时长 − 午休时长」计算,而起排时间晚于午休时午休不在窗口内,相减得负数 | 改为累加各空闲区间的实际长度 |
| 模型偶尔冒英文过渡语 | 提示词未约束过渡语语言,模型受到英文工具 schema 的干扰 | 明确要求"包括思考时的过渡语在内全程只用中文" |
| 下午排出的日程全是已过去的时间段 | plan_day 默认从 09:00 开始排 |
当天排期自动对齐当前时间(向上取整到 10 分钟刻度),剩余不足时建议改排明天 |
| PowerShell 脚本报一堆语法错误、中文变乱码 | PS 5.1 读取无 BOM 的 .ps1 时按系统 ANSI(GBK)解析源码 |
脚本存为 UTF-8 with BOM,并在 Python 侧加了"缺 BOM 自动补写"的自愈检查 |
每轮识别后第二次 Recognize() 抛 "No audio input" |
wave 输入被读完后再次调用会抛异常,而不是返回 null | 每次调用单独 try/catch,遇到 null 或异常立即结束循环 |
| 探测返回的引擎列表在只有 1 个时变成了对象 | PowerShell 函数返回单元素数组会被自动解包成标量 | 用 @(...) 包裹,保证字段类型稳定 |
| 录音结束后界面永远卡在"录音中",麦克风不释放 | MediaRecorder.onstop 忘了绑定,收尾逻辑从未执行 |
补上 onstop,并加 2 秒看门狗兜底 |
| 下午使用时"可用时间利用率"为负 / 恒为 0 | 容量按「工作时长 − 午休时长」算,起排晚于午休时得负数 | 改为累加各空闲区间实际长度 |
| 用户反馈"说的话被扭曲成完全不同的句子" | SAPI 8.0 自由听写能力不足(长句仅 53% 相似度),不是音频链路问题 | 先用"合成音频回环 + 时长守恒校验"定位到引擎,再引入 faster-whisper |
| huggingface.co 下载模型超时 / 传到一半断连 | 官方源在部分网络下不可达;大文件传输易中断 | 默认改用国内镜像,并在下载脚本里加断点续传重试(.incomplete 文件自动续传) |
| 大模型纠错把已经正确的话改坏了 | 纠错无差别地对所有识别结果"润色" | 加置信度门限:≥0.7 直接跳过纠错 |
| 用户反馈"根本没有回放的效果" | "识别→自动发送"一气呵成,用户还没听到自己说了什么,消息已经发出去了;手动回放按钮藏在工具条里没被注意到 | 新增「录音后回放」开关(默认开):录音一停就自动把录音播一遍,与识别并行进行,同时按钮切到「回放中…」态 |
| 回放结束时把识别信息提示一起清掉了 | 提示位没有区分来源,回放结束的清理动作误伤了识别信息 | 给提示加来源标记,只有"仍属于回放"时才清理 |
| "新加的「录音后回放」开关点了没反应" | Starlette 的 StaticFiles 默认不发 Cache-Control,浏览器启用"启发式缓存"(按 Last-Modified 推算新鲜期),于是用户看到的是新 index.html,跑的却是旧 voice.js —— 新开关没有对应的 JS,自然点不动 |
① 静态资源统一加 no-cache, must-revalidate(走 ETag 304);② index.html 里的 app.js/voice.js/style.css 自动带上内容版本号 ?v=<sha1>,内容一变 URL 就变,彻底绕开旧缓存 |
| 「回放录音」按钮在没录过音时就一直显示 | .hints-toggle { display: inline-flex } 这类作者样式会压过浏览器默认的 [hidden] { display: none }(浏览器默认样式属于 UA 来源,优先级最低),hidden 属性形同虚设 |
全局补一条 [hidden] { display: none !important; } 兜底 |
| 没录过音时点「回放录音」也没有任何反馈 | addEventListener('click', replayLastRecording) 直接把函数交给浏览器,MouseEvent 被当作第一个参数 auto 传进来;事件对象是真值,于是"还没有可回放的录音"这条提示被静默跳过 |
用箭头函数包一层:() => replayLastRecording(false)。这个坑很隐蔽,因为多数时候参数传错了也不会报错,只是行为不对 |
| 快捷按钮都只是"往输入框塞一句预置话术" | 早期为了省事,所有 chip 都写成 data-prompt:点一下等于替用户打字。其中「记住偏好」预置的是「以后问天气默认用上海」——上海是凭空指定的,用户既不知道为什么是上海,也不方便改成自己的城市;「查天气」同理,预置的「明天北京天气」也和用户真实的所在城市无关 |
① 删掉「记住偏好」这个 chip(偏好功能本身保留,通过自然语言触发即可,remember_preference 工具调用本身就是加分项的最好证明);② 「查天气」改成直接动作 data-action="weather":真正调一次天气接口、出一张卡片,城市按「偏好城市 → 浏览器定位 → 服务端默认」自动决定,卡片末尾注明本次用的是哪一种 |
浙公网安备 33010602011771号