综合实战:个人助理智能体

第二个综合项目更"像产品":一个能用自然语言安排日程、查天气、记备忘的个人助理,还带上三件实战必需品——长期记忆(记住你上次聊过的偏好)、人工审批(删除备忘这类危险操作先问人)、会话持久化(重启后还能续聊)。核心不是"调一次模型",而是让工具、记忆、审批、持久化四个部件协作。

功能与架构

CLI/对话层
   │  ① 召回记忆 ② 组装消息
   ▼
意图与工具循环(LLM 函数调用)
   ├── add_memo / list_memos     备忘(SQLite)
   ├── delete_memo  ★危险→人工审批
   ├── add_schedule/list_schedule 日程(SQLite)
   └── get_weather               天气(HTTP,见第 09 章)
   │  ③ 对话沉淀:偏好写入记忆
   ▼
SQLite:memos / schedules / chat_log / memory(偏好向量)

存储层:一张库四张表

# assistant_store.py
import json, os, sqlite3, time, zlib

DB = "assistant.db"

def conn():
    c = sqlite3.connect(DB)
    c.executescript("""
        CREATE TABLE IF NOT EXISTS memos(id INTEGER PRIMARY KEY AUTOINCREMENT,
            text TEXT, created TEXT);
        CREATE TABLE IF NOT EXISTS schedules(id INTEGER PRIMARY KEY AUTOINCREMENT,
            when_at TEXT, what TEXT);
        CREATE TABLE IF NOT EXISTS chat_log(session_id TEXT, role TEXT, content TEXT, ts TEXT);
        CREATE TABLE IF NOT EXISTS memory(key TEXT PRIMARY KEY, text TEXT,
            vector TEXT, updated_at TEXT);""")
    return c

def add_memo(text: str) -> str:      # 返回给模型看的可读结果
    c = conn(); c.execute("INSERT INTO memos(text, created) VALUES(?, ?)",
                          (text, time.strftime("%m-%d %H:%M"))); c.commit()
    n = c.execute("SELECT COUNT(*) FROM memos").fetchone()[0]
    return f"已记录备忘,当前共 {n} 条"

其余几个存储函数同样"一条 SQL、返回人话文本"(delete_memo 会先确认 id 存在,不存在返回"备忘不存在"):

def add_schedule(when_at: str, what: str) -> str:
    c = conn(); c.execute("INSERT INTO schedules(when_at, what) VALUES(?,?)", (when_at, what)); c.commit()
    return f"已安排:{when_at} {what}"

def delete_memo(memo_id: int) -> str:
    c = conn(); cur = c.execute("DELETE FROM memos WHERE id=?", (memo_id,))
    c.commit()
    return "已删除该备忘" if cur.rowcount else "备忘不存在"

def list_memos() -> str:
    rows = conn().execute("SELECT id, text, created FROM memos ORDER BY id DESC LIMIT 10").fetchall()
    return "\n".join(f"#{i} {t}({d})" for i, t, d in rows) or "(暂无备忘)"

def list_schedule() -> str:
    rows = conn().execute("SELECT id, when_at, what FROM schedules ORDER BY id DESC LIMIT 10").fetchall()
    return "\n".join(f"#{i} {w} {t}" for i, w, t in rows) or "(暂无日程)"

工具只返回文本,模型才能自然地说给用户听——这是第 05 章"工具输出要为模型设计"的落地。

长期记忆:偏好写入与向量召回

沿用第 13 章"sqlite 存向量 + 余弦召回"的 B 路实现(离线 debug 向量,正式环境换 embedding 服务,接口不变):

def _embed(text: str, dim: int = 128) -> list:
    v = [0.0] * dim                       # 教学伪向量:字符二元组哈希
    for i in range(len(text) - 1):
        v[zlib.crc32(text[i:i+2].encode()) % dim] += 1.0
    n = sum(x*x for x in v) ** 0.5 or 1.0
    return [x/n for x in v]

def remember(key: str, text: str) -> None:
    c = conn()
    c.execute("INSERT INTO memory(key,text,vector,updated_at) VALUES(?,?,?,?) "
              "ON CONFLICT(key) DO UPDATE SET text=excluded.text, vector=excluded.vector, updated_at=excluded.updated_at",
              (key, text, json.dumps(_embed(text)), time.strftime("%Y-%m-%d %H:%M:%S")))
    c.commit()

def recall(query: str, top_k: int = 2) -> list:
    qv = _embed(query); rows = conn().execute("SELECT text, vector FROM memory").fetchall()
    def cos(a, b):
        return sum(x*y for x, y in zip(a, b)) / ((sum(x*x for x in a)**.5 or 1) * (sum(y*y for y in b)**.5 or 1))
    return [t for t, _ in sorted(((t, cos(qv, json.loads(vec))) for t, vec in rows),
                                 key=lambda kv: kv[1], reverse=True)[:top_k]]

工具清单:给模型的 JSON Schema

TOOLS = [
    {"type": "function", "function": {"name": "add_memo", "description": "记录一条备忘",
     "parameters": {"type": "object", "properties": {"text": {"type": "string", "description": "备忘内容"}}, "required": ["text"]}}},
    {"type": "function", "function": {"name": "delete_memo", "description": "删除一条备忘(危险操作,执行前会向用户确认)",
     "parameters": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}}},
    {"type": "function", "function": {"name": "add_schedule", "description": "新增日程",
     "parameters": {"type": "object", "properties": {"when_at": {"type": "string", "description": "时间如 明天 14:00"}, "what": {"type": "string"}}, "required": ["when_at", "what"]}}},
    {"type": "function", "function": {"name": "get_weather", "description": "查询城市天气",
     "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}},
    {"type": "function", "function": {"name": "list_memos", "description": "列出最近的备忘,用户问'我有哪些备忘'时调用",
     "parameters": {"type": "object", "properties": {}}}},
    {"type": "function", "function": {"name": "list_schedule", "description": "列出最近的日程,用户问'我有什么安排'时调用",
     "parameters": {"type": "object", "properties": {}}}},
]
DANGEROUS = {"delete_memo"}          # 需要人工审批的工具集

每个 description 都写清"何时用、参数含义、是否危险"——模型反复用错参数时,先改这里(呼应第 05、32 章结论)。

天气工具:一次真实 HTTP 调用

查天气不落库,每次实时请求免费接口 wttr.in(异常要兜底,不能让工具把异常抛给循环):

def get_weather(city: str) -> str:
    """按城市查天气;网络失败返回兜底文案而非抛异常(第 09 章 HTTP 工具思路)。"""
    try:
        import requests
        r = requests.get(f"https://wttr.in/{city}?format=3&lang=zh", timeout=8)
        return r.text.strip() or f"{city} 暂无天气数据"
    except Exception:
        return "天气服务暂时不可用,请稍后再试"

工具执行循环 + 人工审批

危险操作执行前调用 approve_fn(CLI 里弹确认,Web 服务里返回审批请求交给前端,见第 19 章思路):

def dispatch(name: str, args: dict, approve_fn) -> str:
    if name == "delete_memo" and not approve_fn(args):
        return "用户拒绝了删除操作,请向用户说明并停手。"
    return {"add_memo": lambda a: add_memo(a["text"]),
            "delete_memo": lambda a: delete_memo(a["id"]),
            "add_schedule": lambda a: add_schedule(a["when_at"], a["what"]),
            "get_weather": lambda a: get_weather(a["city"]),
            "list_memos": lambda a: list_memos(),
            "list_schedule": lambda a: list_schedule()}[name](args)

def ask_confirm(args: dict) -> bool:
    return input(f"⚠ 确认删除备忘 #{args.get('id')} 吗?(y/N) ").strip().lower() == "y"

删除类工具一律先确认再执行——模型只负责"提议",人负责"拍板"。

对话循环:记忆 × 会话 × 工具

import os
from openai import OpenAI

client = OpenAI(api_key=os.getenv("LLM_API_KEY"), base_url=os.getenv("LLM_BASE_URL"))
MODEL = os.getenv("LLM_MODEL", "deepseek-chat")
PREF_KEYWORDS = ("喜欢", "习惯", "不吃", "尽量", "每天")

def run_turn(session_id: str, user_text: str) -> str:
    c = conn()
    hits = recall(user_text)
    sys_extra = ("\n用户长期偏好(可参考):" + ";".join(hits)) if hits else ""
    messages = [{"role": "system", "content": "你是个人助理,调用工具办事,回答简洁。" + sys_extra}]
    for role, content in c.execute(
            "SELECT role, content FROM chat_log WHERE session_id=? ORDER BY rowid DESC LIMIT 6",
            (session_id,)).fetchall()[::-1]:
        messages.append({"role": role, "content": content})
    messages.append({"role": "user", "content": user_text})

    while True:                                   # 工具循环(同第 06 章)
        resp = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS)
        msg = resp.choices[0].message
        if not msg.tool_calls:
            break
        messages.append(msg.model_dump(exclude_none=True))
        for tc in msg.tool_calls:
            args = json.loads(tc.function.arguments)
            result = dispatch(tc.function.name, args, ask_confirm)
            messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
    answer = msg.content or "(完成)"

    ts = time.strftime("%Y-%m-%d %H:%M:%S")       # 会话持久化 + 偏好沉淀
    c.executemany("INSERT INTO chat_log VALUES(?,?,?,?)",
                  [(session_id, "user", user_text, ts), (session_id, "assistant", answer, ts)])
    if any(k in user_text for k in PREF_KEYWORDS):
        remember("pref:" + user_text[:40], user_text)
    c.commit()
    return answer

每次提问前:查长期偏好 + 取最近 6 条历史补进消息;每次回答后:整段对话落库、发现偏好句式就写入记忆——重启进程、换天再聊,"上次说的都还记得"就来自这两张表。

对话轨迹示例(演示,实际措辞随模型)

我> 记个备忘:下周二下午健身,顺便把它排进日程
助手> 好的,两条都办好了;另外记住你习惯把健身放在下午。
      [内部] add_schedule(when_at="下周二 14:00", what="健身") → 已安排

我> 明天上海天气怎么样
助手> 稍等,我查一下。[内部] get_weather(city="上海") → 上海: ☀️ +26°C;明天上海晴,26℃,适合出门。

我> 帮我删掉"下周二健身"那条备忘
助手> [内部] list_memos → 先查到 #2 是"下周二下午健身";决定调用 delete_memo(id=2) → 触发审批
      ⚠ 确认删除备忘 #2 吗?(y/N) n
      好的,已取消删除,不动你的数据。

我> 那日程里有健身吗?(次日重启进程再问,记忆仍在)
助手> [内部] recall("日程里有健身吗") → 命中"健身安排在下午"
      查到了:下周二下午有一条健身日程。

上线边界

  • 多用户:所有表加 user_id 字段,所有查询带条件,别让 A 删到 B 的备忘;
  • Web 化:把 ask_confirm 换成"返回审批请求体",接第 19/28 章的 interrupt 与 /chat 模板即可;
  • 审批要有超时与审计:谁批的、何时批的记一行日志,出问题可追溯;
  • 长期记忆注意隐私:记忆内容等于用户画像,涉及敏感偏好要提供"查看/删除我的记忆"入口。 小结:个人助理 = SQLite 管好备忘/日程/会话 + 向量召回实现长期记忆 + 函数调用循环让模型编排工具 + 危险工具先过人工审批;四件事各自独立又通过"每次提问前召回、每次回答后沉淀"串成闭环——这也是生产级助理的最小骨架。
笔记加载中…