第四次作业

这个作业属于哪个课程 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 场景选择

我们选择做任务/待办管理助理。理由有三点:

  1. "会做"的痕迹最直观。任务管理天然对应数据库写入、文件导出、日程计算等确定性操作,"智能体真的动手了"这件事可以被逐条验证,而不是只能"看起来像在干活"。
  2. 能覆盖作业要求的全部技术点。既有纯内部逻辑(优先级打分、日程编排),也有外部 API 调用(天气),还天然需要多轮对话("刚才那个任务改到周五")。
  3. 对后续大项目有复用价值。任务/记忆/工具调度这套骨架可以直接迁移到后面的项目里。

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 主流程:一句话 → 任务落库 → 人话答复

sequenceDiagram participant U as 用户 participant W as 网页/CLI participant A as AgentCore 主循环 participant L as DeepSeek participant M as MCP Client participant S as MCP Server participant D as SQLite U->>W: 「明天下午3点前交作业,这个最重要」 W->>A: POST /api/chat(SSE) A->>D: 读取短期记忆 + 长期摘要 A->>L: system 提示 + 记忆 + 23 个工具 schema L-->>A: tool_calls: add_task(title, due_date="明天下午3点", priority="high") A-->>W: 事件流:tool_call(界面显示"正在执行") A->>M: tools/call: add_task M->>S: JSON-RPC over stdio S->>D: INSERT INTO tasks(...) S-->>M: {"ok":true,"message":"已创建任务 #1…"} M-->>A: 工具结果 A-->>W: 事件流:tool_result(界面卡片转为"已完成") A->>L: 回填 role=tool 结果 L-->>A: 最终自然语言答复 A-->>W: 事件流:final A->>D: 落库消息、必要时压缩记忆

2.2 对话循环(Agent 主循环)

flowchart TD A[接收用户输入] --> B[写库并组装上下文<br/>system + 摘要 + 最近 N 轮] B --> C[调用大模型(流式)] C --> D{是否返回 tool_calls?} D -- 否 --> E[得到最终答复] D -- 是 --> F[逐个经 MCP 执行工具] F --> G[结果以 role=tool 回填] G --> H{轮次 < 上限?} H -- 是 --> C H -- 否 --> I[提示用户拆分需求] E --> J[落库 + 记忆压缩] I --> J J --> K[结束]

关键设计:把"模型思考"和"工具执行"严格分离。模型只负责决定调哪个工具、传什么参数,
参数的合法性、时间解析、排序规则、文件写入全部由 MCP Server 用确定性代码完成。
因此同一个工具既服务于 Agent,也能被任何 MCP 客户端直接调用。

2.3 任务生命周期

stateDiagram-v2 [*] --> todo: add_task todo --> doing: update_task(status=doing) todo --> done: complete_task doing --> done: complete_task done --> todo: update_task(status=todo) 重新打开 todo --> [*]: delete_task(confirm=true) doing --> [*]: delete_task(confirm=true)

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 系统架构

graph LR U[用户] --> WEB[FastAPI + 网页前端] U --> CLI[CLI 终端] WEB -->|SSE 事件流| AG[AgentCore 主循环] CLI --> AG AG -->|chat + tools| DS[DeepSeek API] AG -->|记忆读写| MEM[(SQLite<br/>sessions/messages/preferences)] AG -->|tools/call| MC[MCP Client stdio] MC -->|JSON-RPC| SRV[MCP Server: todo-assistant] SRV -->|任务 CRUD| DB[(SQLite tasks)] SRV -->|HTTPS| WX[Open-Meteo 天气 API] SRV -->|写文件| FS[exports/*.md] MIC[麦克风按钮] -->|浏览器内转 16k WAV| VS[VoiceService] VS -->|JSON 行| PW[PowerShell 常驻 worker] PW -->|识别| VS VS -->|可选纠错| DS VS -->|识别文本| AG AG -->|回复| SPK[喇叭按钮 / 自动朗读] SPK -->|文本| VS VS --> PW PW -->|Huihui 合成 WAV| SPK

主链路(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(当时选中) 系统自带、完全离线,本机恰好已装中文识别引擎与中文音色

第二版:被真实使用推翻

交付后同学实际使用发现"说出去的话被扭曲成完全不同的句子"。我们做了三步定位:

  1. 先排除自己的锅:实测 4 秒录音走完整条转换链,decodeAudioData 解出 3.96 秒
    (speedRatio 0.989),采样率 48kHz→16kHz 时长守恒,WAV 头 16000Hz/16bit/单声道 完全正确。
    音频没有被变形。
  2. 再量引擎的真实上限:用音质完美的合成音频(不含任何麦克风因素)测一批指令 ——
    SAPI 的自由听写平均只有 80.4%,且句子越长越崩:
    「帮我记一下明天下午三点交作业」→「当我亲一下明天3:00PM交作业」(53%)、
    「提醒我周五晚上七点开会」→「停火周五晚上七点开会」(76%)。
  3. 顺带发现一个真问题:录音时开着 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 句明显改意。

最终架构是两个引擎 + 自动选择:

flowchart LR A[录音 WAV] --> B{VOICE_STT_PROVIDER} B -->|auto / whisper| C[faster-whisper<br/>离线本地模型] B -->|sapi| D[Windows SAPI<br/>系统自带] C -->|依赖或模型缺失| D C --> E[识别文本] D --> E E --> F{置信度 >= 0.7?} F -->|是| G[直接使用] F -->|否| H[大模型纠错 + 长度守卫] H --> G G --> I[交给 Agent 主循环]

这样既拿到了高精度,又不丢"任何机器 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 关键提示词约束

系统提示词里有几条硬性约束,都是针对大模型常见失败模式写的:

  1. 先查后答:涉及任务内容必须先调 list_tasks,绝不凭记忆编造任务;
  2. 删除必须先确认:先以 confirm=false 调一次,用户同意后才 confirm=true;
  3. 时间照抄口语:due_date 直接传用户原话,不需要模型自己换算;
  4. 工具返回的 JSON 不要原样贴给用户,要翻译成人话;
  5. 信息缺失就追问,不要擅自猜测;
  6. 全程只用中文:最初实测发现模型偶尔会在工具调用前冒出一句英文过渡语
    ("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;未配置密钥程序不会崩溃,会给出中文提示。

访问 http://127.0.0.1:8000。

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 界面总览

对话工作台:左侧是今日进度环与待办速览,中间是对话流,底部是快捷指令与输入框。

01-chat

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

02-today

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

03-tasks

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

04-settings

深色主题:

05-dark

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 分钟)、
当天时间轴、以及三条排不进去的任务各自的原因,最后还主动问要不要调整。

06-chat-agent

这张图同时证明了四件事:

  1. 模型确实在做工具选择(判断出需要"记录 + 排序 + 排期"三件事);
  2. 参数是模型自己填的,「后天上午10点」被原样传给工具并由工具层解析;
  3. 工具真的执行了(写库、打分、装箱排期),耗时以毫秒计;
  4. 结构化结果被翻译成了人话,没有把 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 分钟

07-chat-export

⑥ 前端界面的实时反馈

08-chat-brief

对话中触发的每次工具调用都会以卡片形式实时呈现(工具名、参数摘要、耗时、结果),
点击卡片可展开原始返回内容 —— 让"智能体在做什么"完全透明。
底部状态栏还会显示本轮模型调用次数与 token 消耗。

4.5 语音交互(加分项④)

① 说话下单:点输入框左侧的麦克风按钮开始录音(也可按 Ctrl+Shift+M),
按钮变为渐变填充并出现呼吸光环,旁边实时显示录音计时。

② 录音结束立即回放:一停下就把刚才录到的声音播一遍(默认开启,可关),
与识别并行进行,按钮同时切到「回放中…」。

这一条是交付后补上的。最初的做法是"识别完直接自动发送",结果用户根本没机会听到
自己说了什么 —— 识别错了也说不清是麦克风的问题还是引擎的问题。现在录音一停就能听到原声,
配合输入框上方的「录音峰值 −3dBFS」,几秒钟就能判断问题出在哪一环:
声音本身正常 → 引擎问题;声音发闷/很小 → 麦克风或系统输入音量问题。

09-voice-recording

④ 识别结果自动填入并发送:录音结束后浏览器把音频转成 16kHz 单声道 WAV 上传识别,
识别文本自动填入输入框并发送。输入框上方会显示本次识别的元信息
(用时、是否命中指令模板、置信度)。

实测:说「今天我先做哪个」→ 识别用时 2067ms、命中指令模板、置信度 99%,
随后自动进入 /api/chat 链路,助理调用工具并给出真实答复。

⑤ 未命中模板时靠纠错:随便说一句不在模板里的话,自由听写会出错,
但纠错层能把同音字改回来(详见 3.3 节 (9) 的实测对照表)。

⑥ 语音指令参考:不确定怎么说时展开这个面板,里面按场景列出了识别引擎最推荐的说法
(这些短语同时是受限语法的候选,照说识别率接近 100%),点任意例句即可填入输入框。

10-voice-hints

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

11-voice-speaker

⑧ 自动朗读与录音后回放开关:输入区右侧的两个胶囊开关分别控制
"助理每次回复是否自动播报"与"录音结束是否自动回放自己的录音",
状态都记在浏览器本地。旁边的「回放录音」按钮可随时再听一遍最近一次录音。
状态记在浏览器本地,刷新后保持。

⑨ 设置页状态卡:展示识别引擎、合成音色、纠错状态与各项限制,
并提供「试听合成效果」按钮方便现场演示。

12-voice-settings

⑩ 环境自检(可现场运行):

$ 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":真正调一次天气接口、出一张卡片,城市按「偏好城市 → 浏览器定位 → 服务端默认」自动决定,卡片末尾注明本次用的是哪一种
posted @ 2026-10-25 20:47  zzhihao  阅读(4)  评论(0)    收藏  举报