news 2026/9/7 1:41:09

AI Agent开发实战:让AI倾听往事并自动撰写回忆录

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AI Agent开发实战:让AI倾听往事并自动撰写回忆录

想象这样一个场景:家里的长辈拿起手机,像平常聊天一样随口说了一句“我年轻的时候在厂里当车工,有一年评先进,车间主任把我叫到办公室……”手机对面的 AI 不急着讲道理,而是轻轻应了一句“那后来呢?”等长辈把这段往事讲完,AI 已经把其中的人名、年份、地点、情绪悄悄整理成了结构化记忆。攒上几十段这样的记忆碎片,一本属于老人的回忆录就慢慢有了雏形。

这篇文章将完整拆解背后这个 AI Agent 智能体开发过程,也就是“给 AI 讲故事,AI 帮你写成回忆录”。这里既有 Agent 的意图判断、结构化抽取、长期记忆设计,也有语音输入、回忆录生成、接口封装等工程落地细节。如果你想做一个 AI 养老方向的智能体应用,或者正在学 agent 开发,这篇文章能给你一套可以直接跑起来的最小闭环。

1. 背景与场景:为什么“AI 陪聊 + 回忆录”适合用智能体来做

1.1 养老场景的真实痛点

老年人的精神陪伴需求,长期被简单概括为“多陪陪老人”。但现实中,子女工作忙、异地居住、生活节奏快,真正能坐下来听老人讲完整段往事的机会并不多。

更可惜的是,老人的口头记忆一直在流失。很多老人年轻时经历的年代故事、家族迁徙、职业变迁、人生关键节点,从来没有被系统记录过。等老人讲不动了,这些故事也就永远消失了。

传统方案里,有人用录音笔把老人的讲述录下来,有人拿笔记本随手记。但录音笔的问题在于:录完就变成了难以检索的音频文件,几小时的录音很难转成文字,更别说整理成有结构的回忆录。手写笔记则更依赖记录者的耐心和整理能力,普通人很难坚持。

这正是 AI Agent 可以切入的地方。它不只做语音转文字,还能理解老人的讲述、判断哪些内容是值得保存的人生片段、自动提取时间地点人物、追问遗漏细节,最后把这些碎片拼成一本可阅读、可打印的回忆录。

1.2 回忆录智能体要解决什么问题

这个智能体本质上要完成三件事:

  • 听:接收老人的语音或文字输入,让老人用最自然的方式讲述往事。
  • 记:从对话中提取结构化记忆,比如事件、时间、地点、人物、情感,而不是简单保存聊天记录。
  • 写:在用户要求时,把所有记忆片段整理成有标题、有时间线、有人物关系的回忆录章节。

举个例子。老人说:“我记得是 1982 年,我从黑龙江调到北京,那时候我女儿刚上小学。”

AI 要能识别出:

  • intent 是 storytelling,老人正在讲述过往经历;
  • time_period 是“1982 年”;
  • location 是“黑龙江、北京”;
  • people 包含“女儿”;
  • episode 是“从黑龙江调到北京,当时女儿刚上小学”。

这个结构化的过程,决定了后续回忆录能不能写清楚。如果只是把原话存进数据库,生成时就会变成一堆杂乱文字的堆砌。

1.3 技术定位:它属于哪一类 AI 应用

很多人会把这个项目简单理解成“聊天机器人”。但实际上它比聊天机器人要多两层能力:长期记忆和内容生成。

如果单纯用大模型 API 做多轮对话,模型是无法记住上一轮讲过的事情的。上下文窗口再大,关掉对话就丢失了。回忆录智能体必须把用户讲过的故事沉淀到自己的存储系统里,下次生成时再去检索。这就进入了 AI Agent 的范畴。

从技术组成看,这个项目是多个能力的组合:

  • ASR(自动语音识别):把老人说的话转成文字;
  • LLM(大语言模型):理解意图、抽取信息、生成回复;
  • 结构化输出:让模型按固定 JSON 格式返回内容;
  • 长期记忆:把结构化的故事片段持久化;
  • 内容生成:根据记忆片段写回忆录章节。

这种组合方式,正好对应了当前 agent 开发中很常见的“感知-决策-执行”循环:感知用户输入,决策用户意图,执行保存记忆或生成内容。

2. Agent 智能体开发核心概念

2.1 AI Agent 是什么

AI Agent,中文一般叫智能体,核心特点是“由大模型做决策,自主完成任务”。

传统软件的逻辑是写死的。用户点哪个按钮,程序就执行哪个函数。而 Agent 面对的是开放输入,模型需要先判断“用户想让我干什么”,再决定调用什么能力、按照什么顺序执行。

回到这个项目。用户可能说:

  • “我年轻的时候在部队开过车。”(讲故事)
  • “你好呀,今天天气不错。”(闲聊)
  • “帮我把刚才那些故事整理成回忆录。”(发布指令)

这三句话如果交给固定规则去判断,也能做,但会很脆弱。用户换个说法,规则就失灵了。用大模型做意图识别,能覆盖几乎无限种表达方式。

2.2 Agent、Workflow、RAG 的分工

很多刚接触 agent 开发的读者会把这几个概念搞混,这里简单区分一下:

  • Workflow:固定流程。比如“先转写,再抽取,再入库”,步骤是提前设计好的。
  • Agent:在大模型驱动下动态决策。它可以根据输入决定走哪条路,甚至调用不同工具。
  • RAG(检索增强生成):生成时先从外部知识库检索相关内容,再交给大模型回答。

回忆录智能体并不是纯 Agent,而是“Workflow + Agent + RAG”的混合体。

固定流程负责“转写→抽取→存储”这种确定性步骤;Agent 负责判断“这次输入是讲故事还是下指令”;生成回忆录时,又会先从数据库检索这个用户的全部记忆片段,再让大模型写正文,这就是 RAG 的思想。

2.3 记忆系统:聊天记录不是记忆

在设计智能体时,一个常见误区是把“聊天记录”当成“记忆”。

聊天记录是原始对话流,里面混着大量无效信息,需要检索的时候很难用。而记忆是经过理解、过滤、结构化之后的知识。

举个例子:

  • 聊天记录保存的是:“我记得是 1982 年,我从黑龙江调到北京,那时候我女儿刚上小学。”
  • 结构化记忆保存的是:time_period=1982 年location=黑龙江→北京people=[女儿]episode=从黑龙江调到北京,当时女儿刚上小学

生成回忆录时,程序读的是结构化记忆,而不是原始聊天记录。只有结构化之后,才能按时间排序、按人物归类、按地点串联,才能生成一本逻辑清晰的回忆录。

所以在这个项目中,记忆模块是核心中的核心。它决定了回忆录的下限。

3. 环境准备与项目规划

3.1 技术选型

这个项目我用 Python 实现,主要组件如下:

  • Python 3.10+:语言基础环境;
  • FastAPI:提供 HTTP 接口,方便前端页面调用;
  • openai SDK:调用大模型接口,因为很多大模型平台都提供 OpenAI 兼容接口,这样代码不用绑定某一家厂商;
  • SQLite:保存结构化记忆,零配置、适合 Demo;
  • 浏览器 Web Speech API:实现语音输入,不需要额外申请语音识别服务;
  • Ollama(可选):本地跑大模型,方便调试和演示。

模型方面,本教程没有绑定具体厂商。你可以把接口地址改为任意兼容 OpenAI 格式的大模型服务,也可以本地用 Ollama 跑一个 7B 以上的模型。不同模型对 JSON 输出的稳定性不同,这一点后面会专门讲。

3.2 项目目录结构

整个项目按模块拆分,方便后续扩展:

ai-story-agent/ ├── requirements.txt ├── app/ │ ├── __init__.py │ ├── main.py # FastAPI 入口 │ ├── config.py # 配置读取 │ ├── schemas.py # Pydantic 数据模型 │ ├── llm.py # 大模型调用与 JSON 解析 │ ├── memory.py # 记忆存储模块 │ ├── agent.py # Agent 编排核心逻辑 │ ├── story_writer.py # 回忆录生成模块 │ └── prompt_templates.py # Prompt 模板 ├── static/ │ └── index.html # 前端页面 └── data/ # SQLite 文件目录,自动创建

这种结构比较适合学习。每个文件职责单一,能看到一条清晰的调用链:前端请求 → FastAPI 接口 → Agent 编排 → 大模型调用 → 记忆存储。

3.3 环境变量与依赖

先创建虚拟环境并安装依赖:

python -m venv venv source venv/bin/activate # Windows 下执行 venv\Scripts\activate pip install fastapi uvicorn openai pydantic

为了不写死版本,这里按“当前最新稳定版”安装即可。如果你使用云厂商的大模型服务,需要额外确认 SDK 版本是否兼容 OpenAI 格式。

将配置写入app/config.py,通过环境变量覆盖,方便切换模型服务:

# app/config.py import os # 大模型接口地址:默认指向本地 Ollama 的 OpenAI 兼容地址 LLM_BASE_URL = os.getenv("LLM_BASE_URL", "http://localhost:11434/v1") LLM_API_KEY = os.getenv("LLM_API_KEY", "ollama") LLM_MODEL = os.getenv("LLM_MODEL", "qwen2.5:7b") # 是否使用 JSON Mode。部分模型不支持 response_format,会自动降级 USE_JSON_MODE = os.getenv("USE_JSON_MODE", "true").lower() == "true" # SQLite 数据库文件路径 DB_PATH = os.getenv("DB_PATH", "data/memory.db")

关键思路:开发时先用本地小模型验证交互流程,成本为零;验证无误后,再切到更强的云端模型提升稳定性。

4. 系统设计:从讲故事到生成回忆录

4.1 整体流程

整个智能体的流程可以拆成六个步骤:

  1. 用户输入:用户通过页面说话或打字,内容是一段往事。
  2. 意图识别:大模型判断用户是在讲故事、寒暄,还是在发布指令。
  3. 结构化抽取:如果是在讲故事,模型抽取事件、时间、地点、人物、情感、标签。
  4. 记忆保存:把结构化内容写入 SQLite,方便后续检索和排序。
  5. 生成回复:模型对用户说一句温暖的回应,让老人愿意继续讲。
  6. 指令执行:用户要求生成回忆录时,从数据库读取全部记忆,调用大模型分章节生成正文。

前五步每轮对话都会发生,第六步只在用户主动发起时执行。

4.2 数据结构设计

记忆表的结构如下:

字段类型说明
idTEXT记忆唯一 ID
user_idTEXT用户标识,支持多老人使用
episodeTEXT这段故事的核心内容
time_periodTEXT时间段描述,如“1982 年”
locationTEXT地点
peopleTEXT相关人物列表,JSON 数组
emotionTEXT情感倾向
tagsTEXT标签列表,JSON 数组
raw_textTEXT用户原始讲述,用于核对
created_atTEXT保存时间

raw_text字段很多人会忽略,但它在生产环境非常重要。生成回忆录时如果模型编造了内容,可以通过raw_text做人工核对,确认哪些细节是真实存在的。

4.3 Prompt 设计要点

Prompt 是这个项目的灵魂。我们至少需要三类 Prompt:

  • 系统角色 Prompt:设定 AI 是“记忆陪伴智能体”,要倾听、共情、不评价、不打断。
  • 抽取 Prompt:要求模型输出严格 JSON,包含 intent、reply、memory 字段。
  • 写作 Prompt:要求回忆录保持真实、第一人称、不编造。

抽取 Prompt 的核心是给模型一个明确的 JSON 结构。模型只有知道每个字段的含义,才能做出高质量抽取。

5. 核心代码实战:AI 讲故事智能体落地

5.1 数据模型定义

先定义接口层的数据结构:

# app/schemas.py from typing import Optional, List from pydantic import BaseModel, Field class ChatRequest(BaseModel): user_id: str = Field(default="default_user", description="用户/老人标识") message: str = Field(..., min_length=1, description="本次输入内容") session_id: Optional[str] = Field(default=None, description="会话 ID") class MemoryItem(BaseModel): memory_id: str user_id: str episode: str time_period: str location: str people: List[str] = [] emotion: str tags: List[str] = [] raw_text: str created_at: str class BookRequest(BaseModel): user_id: str title: str = Field(default="我的回忆录", description="回忆录标题")

Pydantic 在这里的作用是校验请求参数。比如message不能为空,否则接口直接返回 422 错误,避免空数据进入 Agent 流程。

5.2 大模型调用与 JSON 解析

大模型调用单独放在app/llm.py。这样 agent 和回忆录生成模块都能复用。

# app/llm.py import json import re from openai import OpenAI from config import LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, USE_JSON_MODE client = OpenAI(base_url=LLM_BASE_URL, api_key=LLM_API_KEY) def chat(messages, temperature=0.3): """调用大模型,自动处理 json_mode 降级""" kwargs = { "model": LLM_MODEL, "messages": messages, "temperature": temperature, } if USE_JSON_MODE: kwargs["response_format"] = {"type": "json_object"} try: resp = client.chat.completions.create(**kwargs) except Exception: # 模型不支持 response_format 时,去掉该参数重试 kwargs.pop("response_format", None) resp = client.chat.completions.create(**kwargs) return resp.choices[0].message.content def safe_parse_json(text: str) -> dict: """尽可能从模型输出中解析出 JSON,避免结构化输出失败导致流程中断""" if not text: return {"intent": "chat", "reply": "我在听呢,你慢慢说。", "memory": None} text = text.strip() # 1. 直接解析 try: data = json.loads(text) if isinstance(data, dict): return data except json.JSONDecodeError: pass # 2. 匹配 ```json ... ``` 代码块 match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) if match: try: data = json.loads(match.group(1).strip()) if isinstance(data, dict): return data except json.JSONDecodeError: pass # 3. 截取第一个 { 到最后一个 } 之间的内容 start, end = text.find("{"), text.rfind("}") if start != -1 and end != -1 and end > start: try: data = json.loads(text[start:end + 1]) if isinstance(data, dict): return data except json.JSONDecodeError: pass # 4. 兜底:当作普通聊天 return {"intent": "chat", "reply": text, "memory": None}

这里safe_parse_json是保证系统健壮性的关键。真实环境下,模型输出不总是合法 JSON,尤其本地小模型更容易翻车。我们要把“解析失败导致整个请求报错”的概率降到最低。

5.3 记忆存储模块

记忆模块使用 SQLite。每个请求创建独立连接,写入后立即关闭,避免长连接带来的多线程问题。

# app/memory.py import json import sqlite3 import uuid from datetime import datetime, timezone from config import DB_PATH def get_conn(): conn = sqlite3.connect(DB_PATH, timeout=10) conn.row_factory = sqlite3.Row return conn def init_db(): conn = get_conn() try: conn.execute(""" CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, episode TEXT NOT NULL, time_period TEXT, location TEXT, people TEXT, emotion TEXT, tags TEXT, raw_text TEXT, created_at TEXT ) """) conn.execute( "CREATE INDEX IF NOT EXISTS idx_user_time ON memories(user_id, created_at)" ) conn.commit() finally: conn.close() def save_memory(user_id, memory, raw_text): memory_id = str(uuid.uuid4()) created_at = datetime.now(timezone.utc).isoformat() conn = get_conn() try: conn.execute( """ INSERT INTO memories (id, user_id, episode, time_period, location, people, emotion, tags, raw_text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( memory_id, user_id, memory.get("episode", ""), memory.get("time_period", ""), memory.get("location", ""), json.dumps(memory.get("people", []), ensure_ascii=False), memory.get("emotion", ""), json.dumps(memory.get("tags", []), ensure_ascii=False), raw_text, created_at, ), ) conn.commit() return memory_id finally: conn.close() def list_memories(user_id): conn = get_conn() try: rows = conn.execute( "SELECT * FROM memories WHERE user_id = ? ORDER BY created_at ASC", (user_id,), ).fetchall() return [dict(row) for row in rows] finally: conn.close() def delete_memory(user_id, memory_id): conn = get_conn() try: conn.execute( "DELETE FROM memories WHERE user_id = ? AND id = ?", (user_id, memory_id), ) conn.commit() finally: conn.close()

存储时用json.dumps把人物和标签列表变成 JSON 字符串。取出来使用时再json.loads还原。SQLite 没有数组类型,这是常见的做法。

5.4 Agent 编排核心逻辑

Agent 编排是核心。这里定义两类 Prompt,并实现run_agent函数。

# app/prompt_templates.py SYSTEM_PROMPT = """你是一位耐心的记忆陪伴智能体,正在陪伴一位长辈聊天。 你的任务是倾听长辈讲述的往事,用温和的语气回应,并从对话中保存值得记录的人生片段。 要求: 1. 不要编造长辈没有说过的信息。 2. 回应要简短自然,像晚辈在听长辈讲故事,不要说教,不要像客服。 3. 当对方明确要求生成或整理回忆录时,将 intent 设为 command。 """ EXTRACT_PROMPT = """请分析用户的输入,输出严格 JSON,格式如下: { "intent": "storytelling 或 chat 或 command", "reply": "你作为倾听者对用户说的回应语,简短自然", "memory": null } 如果 intent 是 storytelling,则 memory 必须包含: { "episode": "这段故事的核心内容,保留关键细节", "time_period": "时间段,如 1982 年,用户没说就写不详", "location": "地点,用户没说就写不详", "people": ["人物1", "人物2"], "emotion": "整体情感倾向", "tags": ["标签1", "标签2"] } 规则: - storytelling 表示用户在讲述过往经历。 - chat 表示普通寒暄,比如你好、在吗、今天天气不错。 - command 表示用户要求生成回忆录、整理故事、写成一本书等。 - 只提取用户明确提到的信息,绝不编造。 用户输入: {message} """ WRITER_PROMPT = """你是回忆录写手。根据提供的真实素材,写回忆录的一章。 要求: 1. 使用长辈第一人称口吻,语言朴实自然。 2. 按素材中的时间顺序组织内容。 3. 只使用素材中出现的信息,绝不补充细节。 4. 每一章给一个小标题。 5. 直接输出正文,不要输出任何解释说明。 素材如下: {materials} """

下面是 agent 编排模块:

# app/agent.py from config import USE_JSON_MODE from llm import chat, safe_parse_json from memory import save_memory from prompt_templates import SYSTEM_PROMPT, EXTRACT_PROMPT def run_agent(user_id, message): user_prompt = EXTRACT_PROMPT.format(message=message) raw = chat( [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_prompt}, ], temperature=0.3, ) data = safe_parse_json(raw) intent = data.get("intent", "chat") reply = data.get("reply", "我在听,你慢慢说。") memory = data.get("memory") memory_saved = False if intent == "storytelling" and isinstance(memory, dict) and memory.get("episode"): save_memory(user_id, memory, raw_text=message) memory_saved = True return { "intent": intent, "reply": reply, "memory_saved": memory_saved, "memory": memory, }

可以看到,真正的业务逻辑并不复杂:调用模型,解析 JSON,判断意图,保存记忆,返回结果。这就是 Agent 的一种简单落地形态。

5.5 回忆录生成模块

生成回忆录时,要考虑长文本问题。如果用户已经存了几百段故事,一次性全部丢给大模型,很可能超出上下文窗口。所以这里按每 6 个记忆片段生成一章,最后拼接成完整回忆录。

# app/story_writer.py import json from llm import chat from memory import list_memories from prompt_templates import SYSTEM_PROMPT, WRITER_PROMPT CHAPTER_SIZE = 6 def _format_materials(memories): lines = [] for index, item in enumerate(memories, 1): try: people = "、".join(json.loads(item.get("people") or "[]")) except json.JSONDecodeError: people = "不详" lines.append( f"片段{index}:时间={item.get('time_period', '不详')}" f";地点={item.get('location', '不详')}" f";人物={people}" f";内容={item.get('episode', '')}" ) return "\n".join(lines) def _generate_chapter(memories, chapter_no): materials = _format_materials(memories) prompt = WRITER_PROMPT.format(materials=materials) text = chat( [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": prompt}, ], temperature=0.7, ) return f"## 第 {chapter_no} 章\n\n{text.strip()}" def generate_book(user_id, title="我的回忆录"): memories = list_memories(user_id) if not memories: return "暂时还没有足够的故事素材,请先让长辈讲几段往事。" chapters = [] for i in range(0, len(memories), CHAPTER_SIZE): chunk = memories[i:i + CHAPTER_SIZE] chapter = _generate_chapter(chunk, i // CHAPTER_SIZE + 1) chapters.append(chapter) return f"# {title}\n\n" + "\n\n".join(chapters)

生成时temperature=0.7是为了让文字更自然。在抽取环节用0.3,是为了让模型更稳定、更少发挥。

5.6 FastAPI 接口与前端页面

最后把所有模块串起来。

# app/main.py from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from agent import run_agent from memory import delete_memory, init_db, list_memories from schemas import BookRequest, ChatRequest from story_writer import generate_book app = FastAPI(title="AI 回忆录智能体") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) @app.on_event("startup") def startup(): init_db() @app.get("/") def index(): return FileResponse("static/index.html") @app.post("/api/chat") def chat(req: ChatRequest): return run_agent(req.user_id, req.message) @app.get("/api/memories") def memories(user_id: str = "default_user"): return list_memories(user_id) @app.post("/api/book") def book(req: BookRequest): content = generate_book(req.user_id, req.title) return {"user_id": req.user_id, "title": req.title, "content": content} @app.delete("/api/memories/{memory_id}") def remove_memory(memory_id: str, user_id: str = "default_user"): delete_memory(user_id, memory_id) return {"deleted": True}

前端页面可以做一个极简版本。这里使用浏览器自带的 Web Speech API 做语音识别,Chrome 系浏览器支持较好,而且不需要申请额外的语音服务 key。

<!-- static/index.html --> <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>AI 回忆录智能体</title> <style> body { font-family: sans-serif; max-width: 760px; margin: 40px auto; padding: 0 16px; } h1 { font-size: 22px; } #chatBox { border: 1px solid #ddd; border-radius: 8px; height: 320px; overflow-y: auto; padding: 12px; background: #fafafa; } #chatBox div { margin: 8px 0; } .user { text-align: right; color: #1a73e8; } .ai { color: #333; } .controls { display: flex; gap: 8px; margin-top: 12px; } input { flex: 1; padding: 10px; font-size: 16px; border: 1px solid #ddd; border-radius: 6px; } button { padding: 10px 16px; font-size: 15px; border: none; border-radius: 6px; background: #1a73e8; color: #fff; cursor: pointer; } </style> </head> <body> <h1>给 AI 讲故事,AI 帮你写回忆录</h1> <div id="chatBox"></div> <div class="controls"> <input id="msg" placeholder="讲一段往事,或点击“说话”按钮" /> <button id="sendBtn">发送</button> <button id="voiceBtn">说话</button> <button id="bookBtn">生成回忆录</button> </div> <script> const userId = "demo_user"; const chatBox = document.getElementById("chatBox"); const msgInput = document.getElementById("msg"); function appendMessage(role, text) { const div = document.createElement("div"); div.className = role; div.textContent = text; chatBox.appendChild(div); chatBox.scrollTop = chatBox.scrollHeight; } async function sendChat(text) { if (!text.trim()) return; appendMessage("user", text); msgInput.value = ""; const res = await fetch("/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id: userId, message: text }) }); const data = await res.json(); appendMessage("ai", data.reply); if (data.memory_saved) { appendMessage("ai", "(这段故事我已经记下来了)"); } } document.getElementById("sendBtn").onclick = () => sendChat(msgInput.value); document.getElementById("bookBtn").onclick = async () => { appendMessage("ai", "好的,我来试着把之前的故事整理成回忆录……"); const res = await fetch("/api/book", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id: userId, title: "我的回忆录" }) }); const data = await res.json(); chatBox.innerHTML = ""; appendMessage("ai", data.content.replace(/\n/g, "\n")); }; const SR = window.SpeechRecognition || window.webkitSpeechRecognition; if (SR) { const recognition = new SR(); recognition.lang = "zh-CN"; recognition.continuous = false; recognition.interimResults = false; recognition.onresult = (event) => { const text = event.results[0][0].transcript; sendChat(text); }; recognition.onerror = (event) => { appendMessage("ai", "语音识别失败:" + event.error); }; document.getElementById("voiceBtn").onclick = () => recognition.start(); } else { document.getElementById("voiceBtn").disabled = true; document.getElementById("voiceBtn").textContent = "浏览器不支持语音"; } document.getElementById("msg").addEventListener("keydown", (e) => { if (e.key === "Enter") sendChat(msgInput.value); }); </script> </body> </html>

这个页面追求的是“能跑、好理解”,没有引入前端框架。生产环境完全可以用 Vue、React 重写,但逻辑是相同的。

6. 运行与验证

6.1 启动服务

在项目根目录执行:

uvicorn app.main:app --reload --port 8000

如果使用本地 Ollama 模型,需要确保 Ollama 已启动,并且已经拉取对应模型:

ollama pull qwen2.5:7b

然后打开浏览器访问http://127.0.0.1:8000/,可以看到前端页面。

6.2 用 curl 测试接口

模拟用户讲述往事:

curl -X POST http://127.0.0.1:8000/api/chat \ -H "Content-Type: application/json" \ -d '{ "user_id": "demo_user", "message": "我记得1982年我从黑龙江调到北京,那时候我女儿刚上小学。" }'

正常情况下,返回内容类似:

{ "intent": "storytelling", "reply": "那后来呢?女儿在北京适应得怎么样?", "memory_saved": true, "memory": { "episode": "1982年从黑龙江调到北京,当时女儿刚上小学", "time_period": "1982年", "location": "黑龙江,北京", "people": ["女儿"], "emotion": "怀念", "tags": ["工作调动", "家庭"] } }

此时数据库data/memory.db中已经写入一条记忆。

继续讲几段故事后,调用生成回忆录接口:

curl -X POST http://127.0.0.1:8000/api/book \ -H "Content-Type: application/json" \ -d '{"user_id": "demo_user", "title": "我的回忆录"}'

返回的内容是按章节组织的 Markdown 文本。

6.3 预期输出与观察点

运行成功后,重点观察几个环节:

  • 对话中讲故事和普通寒暄是否能被正确区分;
  • 抽取出的 time_period、location、people 是否符合预期;
  • 模型回复是否自然、有没有说教感;
  • 生成回忆录时是否按照时间顺序组织内容;
  • 是否出现模型编造细节的情况。

如果某个环节不稳定,不要急着改代码,先看是不是模型能力不够。小模型在结构化抽取上确实比大模型弱很多,这是正常现象。

7. 常见问题与排查思路

问题现象常见原因解决思路
模型返回内容无法解析成 JSON小模型对 JSON 格式理解能力弱换 7B 以上模型,或关闭 json_mode 依赖兜底解析
接口报错 400大模型服务不支持response_format参数代码已自动降级,检查是否还有其它参数不兼容
故事情节被模型编造Prompt 约束不够强,或模型自由度太高降低 temperature,在 Prompt 中强调“只能使用素材”
SQLite 报 database is locked多线程并发写同一个 SQLite 文件设置 timeout=10;生产环境换 PostgreSQL/MySQL
语音按钮不可用浏览器不支持 Web Speech API换 Chrome/Edge,或接入服务端 ASR 方案
生成内容超出上下文长度记忆片段太多一次性提交使用分章节生成,每章限制素材数量
老人说的话口语化太强,抽取效果差模型对口语理解能力不足先用大模型做口语转书面,再做结构化抽取

这里重点说两个高频问题。

第一个是“小模型经常输出非法 JSON”。这几乎无法通过代码完全规避,只能从三个方向解决:

  • 更换更强的大模型;
  • 在抽取前增加一段“把口语转成简洁文本”的预处理步骤;
  • 对关键业务做校验,JSON 解析失败时不要把错误暴露给用户,而是返回一句兜底回复。

第二个是“模型生成回忆录时编造细节”。自动写作天然有幻觉风险,处理方式不是盲目相信模型输出,而是:

  • 在写作 Prompt 中明确“只使用素材中出现的信息”;
  • 生成后允许人工编辑;
  • 把每段原始讲述raw_text一并展示,方便核对。

8. 最佳实践与工程建议

8.1 隐私与数据安全

养老场景的数据比普通互联网业务更敏感。这里有几条底线:

  • 明确告知:在页面和语音开场中告诉老人“你讲的内容会被记录”,不能偷偷采集。
  • 最小权限:系统只收集生成回忆录所需的信息,不采集无关隐私。
  • 可删除:提供删除单条记忆的接口,老人或家属可以随时清除。
  • 权限隔离:不同老人之间通过user_id完全隔离,不允许跨用户访问。
  • 加密存储:生产环境对数据库做加密,备份文件同样要加密。
  • 人工审核:涉及大模型生成内容,建议增加“人工确认后再发布/打印”的环节。

8.2 老年用户交互体验优化

这个项目的用户不是年轻程序员,而是可能不太会用智能手机的老人。交互设计要尽量简单:

  • 语音优先:能用语音就不让打字,按键要大,反馈要快。
  • 温暖反馈:AI 回复不要只是“好的,已记录”,可以多问一句“那后来呢”,引导老人继续讲。
  • 错误容忍:老人说得断断续续、逻辑跳跃是常态,模型不需要输出标准答案,要能理解和承接。
  • 自动追问:当老人提到一个模糊信息,比如“厂里”、“老领导”,可以在回复中自然地问“当时厂里叫什么名字呀”。

这些交互细节决定了老人愿不愿意长期使用。技术只是前提。

8.3 回忆录内容质量与幻觉控制

生成回忆录时,建议采用“回忆片段 + 章节生成 + 人工校对”的流程:

  • 先让老人用短对话讲出多个故事片段;
  • 每隔一段时间自动汇总成章;
  • 生成后由老人或家属人工确认,把明显错误的部分删掉或改掉;
  • 最后统一排版打印。

不建议在第一天就要求生成完整书籍。回忆录是长周期内容,内容质量比生成速度更重要。

8.4 生产环境落地建议

从 Demo 到生产环境,还需要做几件事:

  • 把 SQLite 替换为 PostgreSQL 或 MySQL,支持并发访问;
  • 用向量数据库保存记忆,按语义检索最相关的故事片段;
  • 接入生产级 ASR 服务,替代浏览器语音识别;
  • 生成回忆录用异步任务队列,避免长文本生成阻塞 HTTP 请求;
  • 增加管理后台,让运营人员能够查看生成记录、处理异常;
  • 记录模型调用日志和 token 消耗,做成本监控;
  • 对模型输出做敏感词过滤和格式校验。

如果不想从零开发流程编排,也可以参考 Dify、Coze 等智能体平台,先跑通产品逻辑,再用代码自研实现更复杂的定制需求。Java 技术栈的开发者还可以参考 LangChain4j、Spring AI 等框架,把这里的模块等价迁移过去。

这个项目的核心价值不在于技术多复杂,而在于把“倾听、记录、整理、成书”完整串起来。回到开头那个场景:老人讲一段往事,AI 认真听、仔细记、最后生成一本可以打印的家史,这件事本身就是 AI 养老方向里很有温度的一次落地。

建议你先把最小闭环跑起来,用两三段真实的故事验证流程,再逐步扩展章节生成、人物关系梳理、时间线可视化等功能。当长辈真的看到自己讲过的故事变成文字时,你会明白这个智能体存在的意义。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 1:40:45

野猫湖平台UFS实战:零刻EQ mini搭配长江存储UC341性能功耗解码

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 1:40:39

Clawdbot深度解析:从功能拆解到商业模式的AI Bot全链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 1:39:44

基于Tcl/Tk的FPGA仿真文件自动获取工具设计

上个月我接手一个历史遗留的FPGA工程&#xff0c;准备跑一遍回归仿真。工程目录结构乱得离谱&#xff0c;RTL源码在src/&#xff0c;testbench在tb/&#xff0c;IP核仿真模型散落在ip/的几个子目录里&#xff0c;还有一些hex和coe初始文件放在data/。我打开ModelSim准备手工把文…

作者头像 李华
网站建设 2026/9/7 1:39:39

分类基础实战:从训练测试集到决策树与过拟合避坑指南

简介&#xff1a;针对《数据挖掘导论&#xff08;第二版&#xff09;》第3章“分类-基础”的配套教学课件&#xff0c;适合数据挖掘初学者、高校师生及相关从业者系统理解分类任务的核心框架。资源以PPTX演示文稿形式呈现&#xff0c;共1个文件&#xff0c;压缩包约1.77MB。课件…

作者头像 李华
网站建设 2026/9/7 1:39:10

AMD Ryzen AI Max+ 395 无头服务器部署 ComfyUI 实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 1:38:41

软件测试流程如何落地?从需求评审到测试报告的实战指南

简介&#xff1a;《测试体系建设之软件测试流程》是一份面向软件测试人员及测试管理者的过程规范文档&#xff0c;系统梳理了从需求评审、测试计划、测试设计、功能测试执行、集成/性能测试设计到文档测试、测试报告发布的完整测试链路。文档针对每个环节明确了目的、角色职责、…

作者头像 李华