1. 从零做一个会聊天的桌宠,Tkinter 透明窗口怎么搭
桌宠智能体这个东西,说白了就是一只常驻桌面的小人/小动物,你点它一下它会动,你问它问题它会答,你让它帮忙看文件它也能动手。它和普通 GUI 程序最大的区别在于:窗口没有标题栏、背景透明、永远浮在桌面最上层,而且不能出现在任务栏里抢焦点。这套效果用 Python 自带的 Tkinter 就能做出来,不需要装 Qt 或 Electron 那种重家伙。
适合谁来跟做?会一点 Python 基础、想给自己桌面加个 AI 小助手、又不想被复杂框架劝退的人。整条路径分三块:透明悬浮窗(Tkinter)、对话逻辑(LLM 请求封装)、模型通道管理(统一 Key 和 Base URL)。前两块是本地代码,第三块决定你后面换模型方不方便。
我先说清楚一个容易踩的坑:Tkinter 的overrideredirect(True)能去掉标题栏,但去掉之后窗口就不受系统窗口管理器正常管理了,拖拽、关闭、置顶都得自己写。透明背景则要靠wm_attributes("-transparentcolor", ...),这个属性只在 Windows 上有效,而且颜色要选一个图片里绝对不会出现的色值,比如纯品红#FF00FF,否则宠物边缘会被抠掉一块。
窗口尺寸建议按宠物图片实际大小来,别写死。加载图片用 Pillow,因为它能保留 PNG 的 alpha 通道,Tkinter 自带的 PhotoImage 对透明支持很差。下面这段是主窗口骨架,可以直接跑:
import tkinter as tk from PIL import Image, ImageTk class PetWindow: def __init__(self, img_path="pet_picture/dog.png"): self.root = tk.Tk() self.root.overrideredirect(True) # 去掉标题栏 self.root.wm_attributes("-topmost", True) # 永远置顶 self.root.wm_attributes("-transparentcolor", "#FF00FF") # 透明色键 self.root.config(bg="#FF00FF") img = Image.open(img_path).convert("RGBA") self.photo = ImageTk.PhotoImage(img) self.label = tk.Label(self.root, image=self.photo, bg="#FF00FF", bd=0) self.label.pack() self._bind_events() self.root.geometry("+800+400") # 初始位置 self.root.mainloop() def _bind_events(self): self.label.bind("<Button-1>", self._on_press) self.label.bind("<B1-Motion>", self._on_drag) def _on_press(self, e): self._dx, self._dy = e.x, e.y def _on_drag(self, e): x = self.root.winfo_x() + e.x - self._dx y = self.root.winfo_y() + e.y - self._dy self.root.geometry(f"+{x}+{y}") if __name__ == "__main__": PetWindow()跑起来你会看到一只没有边框的狗浮在桌面上,按住能拖。这里有个细节:-transparentcolor的色值必须和bg完全一致,差一个十六进制位都会失效。另外如果你发现宠物周围有一圈白边,多半是 PNG 本身带了白色描边,不是代码问题,换张干净的图就行。
窗口搭好只是第一步。真正让它“活”起来的是动画和交互:待机时呼吸缩放、点击时跳一下、右键弹菜单。动画不要用time.sleep在主线程里循环,会把界面卡死。正确做法是用root.after(ms, callback)做定时回调,每 50ms 改一次图片尺寸或窗口位置,形成连续动画。呼吸动画就是让缩放系数在 0.95 到 1.05 之间来回走,用正弦函数最自然:
import math def breathing(self, t=0): scale = 1.0 + 0.05 * math.sin(t / 10) w = int(self.base_w * scale) h = int(self.base_h * scale) resized = self.orig_img.resize((w, h), Image.LANCZOS) self.photo = ImageTk.PhotoImage(resized) self.label.config(image=self.photo) self.root.after(50, lambda: self.breathing(t + 1))右键菜单用tk.Menu(tearoff=0),绑定<Button-3>弹出。菜单项里放“大模型配置”“任务看板”“记忆管理”这些入口,后面接 LLM 和工具模块都从这里进。到这一步,一个能拖、能动、能弹菜单的桌宠壳子就完成了,接下来才是接大脑。
2. 接入 LLM 前,先把 TaoToken 的 Key 和通道准备好
桌宠要会聊天,就得调大模型 API。这里有个现实问题:市面上的模型服务商太多,OpenAI、DeepSeek、通义、智谱各有各的接口地址和 Key,你要是每接一个就改一次代码、存一份 Key,配置会乱成一团。更麻烦的是有些服务商网络不稳定,或者你想在几个模型之间切换对比效果,每次都要翻代码找base_url。
我的做法是走一个统一的 API 通道,把 Key 和 Base URL 收敛到一处。TaoToken 就是干这个的:它提供 OpenAI 兼容的接口,你拿一个 Key 就能调多种模型,代码里只认一个base_url,换模型只改model字段。官网在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 根地址是 https://taotoken.net/api ,注意这个地址后面不加任何参数。
为什么强调 OpenAI 兼容?因为 Python 生态里openai这个库已经成了事实标准,很多第三方 SDK 都按它的格式来。你只要把base_url指过去,client.chat.completions.create(...)这套调用方式原封不动就能用。桌宠的 LLM 封装层因此可以写得很薄,不用为每个服务商写适配器。
拿 Key 的流程不复杂:进控制台,创建一个 API Key,复制出来存好。控制台入口是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,Key 管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。Key 只在创建时完整显示一次,记得当场存进密码管理器或本地配置文件,别截图发群里。
这里要提醒一句:Key 属于敏感凭证,不要硬编码进pet.py然后传到公开仓库。桌宠项目我建议单独放一个pet_config.json,把 Key 写进去,同时把pet_config.json加进.gitignore。运行时读取配置,代码里永远不出现明文 Key。
配置结构可以设计成多提供商多模型,方便以后扩展。llm_providers存服务商(名称、base_url、api_key),llm_models存模型(属于哪个提供商、模型名、上下文窗口、压缩阈值),llm_active记录当前激活的是哪个。这样你在桌宠的“大模型配置”窗口里就能可视化地增删改,不用碰代码。数据库用 SQLite,建表语句大致如下:
CREATE TABLE llm_providers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, base_url TEXT NOT NULL, api_key TEXT NOT NULL, enabled INTEGER DEFAULT 1, sort_order INTEGER DEFAULT 0 ); CREATE TABLE llm_models ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id INTEGER NOT NULL, name TEXT NOT NULL, enabled INTEGER DEFAULT 1, max_context_tokens INTEGER DEFAULT 32000, compress_threshold REAL DEFAULT 0.8, min_recent_rounds INTEGER DEFAULT 5 ); CREATE TABLE llm_active ( id INTEGER PRIMARY KEY, provider_id INTEGER, model_name TEXT );填配置的时候,base_url填https://taotoken.net/api,api_key填你刚创建的那串,model填你想用的模型 ID。具体有哪些模型 ID 可以看接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。文档里会列当前支持的模型名,照着填就行。
如果你后面打算长期跑编码类或 Agent 类任务,比如让桌宠帮你写代码、调工具、跑多步流程,可以考虑 Coding Plan,入口在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。它和按量计费的 Key 是两套东西,按自己的使用频率选。想先单纯试试模型对话效果,可以用模型对话页:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 。
把 Key 和通道准备好之后,桌宠的 LLM 封装层就只剩一件事:发请求、收流式响应、把文字塞进气泡或对话窗口。下一节直接上可复制的代码。
3. 可复制的 LLM 请求封装与桌宠配置片段
这一节给你能直接抄进项目的代码。先说配置文件,再说请求封装,最后说怎么和 Tkinter 的界面线程配合。
配置文件pet_config.json长这样,路径白名单和 Shell 白名单也一并放进来,后面工具调用要用:
{ "image": "pet_picture/dog.png", "llm": { "base_url": "https://taotoken.net/api", "api_key": "sk-你的Key填这里", "model": "你的模型ID", "max_context_tokens": 32000, "compress_threshold": 0.8, "min_recent_rounds": 5 }, "allowed_paths": [ "C:/Users/你的用户名/Desktop", "C:/Users/你的用户名/Documents", "C:/Users/你的用户名/Downloads" ], "enabled_shell_commands": ["ipconfig", "ping", "dir", "tree", "systeminfo"], "custom_shell_commands": [] }注意base_url写https://taotoken.net/api,不要在后面加/v1或斜杠,OpenAI SDK 会自己拼路径。api_key和model换成你自己的。这个文件放在项目根目录,和main.py同级。
请求封装用openai库,先pip install openai。封装成一个类,支持流式输出,因为桌宠对话要逐字显示才有感觉:
import json from openai import OpenAI class LLMClient: def __init__(self, config_path="pet_config.json"): with open(config_path, "r", encoding="utf-8") as f: cfg = json.load(f)["llm"] self.model = cfg["model"] self.client = OpenAI( base_url=cfg["base_url"], api_key=cfg["api_key"], ) def chat_stream(self, messages, on_delta=None): """流式对话,每收到一段文字就回调 on_delta""" resp = self.client.chat.completions.create( model=self.model, messages=messages, stream=True, ) full = "" for chunk in resp: delta = chunk.choices[0].delta if delta and delta.content: full += delta.content if on_delta: on_delta(delta.content) return full def chat(self, messages): """非流式,用于生成题目、摘要等短任务""" resp = self.client.chat.completions.create( model=self.model, messages=messages, ) return resp.choices[0].message.contentmessages是标准格式:[{"role": "system", "content": "..."}, {"role": "user", "content": "..."}]。多轮对话就是把历史消息按顺序拼进去。桌宠的系统提示词可以写成“你是一只桌面宠物,说话简短可爱,回答控制在三句话内”,这样气泡不会撑爆。
关键点来了:Tkinter 是单线程的,网络请求会阻塞界面。如果你在按钮回调里直接调chat_stream,整个窗口会卡住直到响应结束,宠物动画全停。解决办法是把请求丢到后台线程,用root.after把结果安全地送回主线程更新 UI:
import threading def on_send(self, user_text): self.messages.append({"role": "user", "content": user_text}) self.append_bubble("你", user_text) def worker(): def on_delta(text): # 不能直接改 UI,用 after 排队 self.root.after(0, lambda: self.append_stream(text)) reply = self.llm.chat_stream(self.messages, on_delta) self.messages.append({"role": "assistant", "content": reply}) threading.Thread(target=worker, daemon=True).start()daemon=True保证主窗口关闭时线程不会拖着进程不放。root.after(0, ...)是 Tkinter 里跨线程更新 UI 的标准姿势,比直接操作控件安全得多。
上下文压缩也在这里做。每次发请求前估算 token 数,超过max_context_tokens * compress_threshold就把中间的历史消息用 LLM 摘要成一段 100-200 字的中文,替换掉原始消息,只保留最近min_recent_rounds轮完整对话。这样长对话不会爆窗口,也不会丢关键信息。
如果你用的是 Claude Code 这类工具做辅助开发,配置方式类似,Base URL 填https://taotoken.net/api,Key 和 Model ID 按文档填。Claude Code 的接入说明在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 里能找到。三件套永远是:Base URL、Key、Model ID,缺一不可。
配置和封装都齐了,下一节验证请求能不能通。
4. 验证请求与本地运行:从 curl 到桌宠气泡
写完代码别急着接界面,先用最小请求验证通道是通的。这一步能帮你把“代码问题”和“配置问题”分开,省很多排查时间。
最直接的方式是用 curl 打一发:
curl https://taotoken.net/api/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的Key" \ -d '{ "model": "你的模型ID", "messages": [{"role": "user", "content": "用一句话介绍你自己"}], "stream": false }'如果返回 JSON 里有choices[0].message.content,说明 Key、Base URL、模型 ID 三样都对。如果报 401,是 Key 问题;报 404,多半是模型 ID 写错或 Base URL 多了路径;报连接超时,检查网络和地址拼写。
curl 通了之后,跑 Python 版:
from llm.client import LLMClient client = LLMClient("pet_config.json") reply = client.chat([ {"role": "system", "content": "你是一只桌面宠物,说话简短。"}, {"role": "user", "content": "今天适合做什么?"}, ]) print(reply)能打印出中文回复,就说明封装层没问题。这时候再把它接到桌宠的气泡上。气泡窗口本身也是个 Toplevel,无边框、半透明、自动消失:
class Bubble: def __init__(self, root, text, x, y, duration=3000): self.win = tk.Toplevel(root) self.win.overrideredirect(True) self.win.wm_attributes("-topmost", True) self.win.wm_attributes("-alpha", 0.9) self.win.geometry(f"+{x}+{y}") tk.Label(self.win, text=text, bg="#FFF8DC", font=("微软雅黑", 10), wraplength=220, justify="left", padx=10, pady=8).pack() self.win.after(duration, self.win.destroy)流式输出时,每收到一段 delta 就更新气泡里的文字。因为气泡是动态创建的,简单做法是第一次收到 delta 时创建气泡,后续 delta 往同一个 Label 追加。注意wraplength要设,不然长回复会横向拉成一条线。
实测下来,从点击发送到第一个字出现,延迟主要在网络往返,通常几百毫秒到一两秒。如果明显卡顿,先确认是不是在主线程里发了请求。另一个常见现象是流式输出时气泡闪烁,那是每次追加都重建了 Label,改成label.config(text=...)复用同一个控件就好。
验证通过后,你可以让桌宠做点实际的事,比如右键菜单点“每日名言”,后台调chat生成一句,显示在气泡里;点“百科问答”打开对话窗口,走多轮chat_stream。这些功能共用同一个LLMClient实例,不要每次新建,否则连接池反复重建,效率低。
到这一步,一个能聊天、能弹气泡、能流式输出的桌宠就跑起来了。接下来是排错环节,这些报错我基本都遇到过。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
桌宠接 LLM 最容易卡在几个固定报错上,我按出现频率排一下,对照着查。
401 Unauthorized。这是 Key 的问题,不是代码问题。检查三处:pet_config.json里的api_key有没有多余空格或换行;Key 是不是已经删除或过期;请求头里Authorization: Bearer后面有没有漏空格。用 curl 单独测一次,能排除代码干扰。如果 curl 也 401,去控制台重新创建一个 Key:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。
local proxy failed / connection error。这个报错通常出现在base_url写错或网络环境异常时。先确认地址是https://taotoken.net/api,没有多余路径、没有尾部斜杠。如果你本地配了系统级网络工具,可能会拦截请求,临时关掉再试。还有一种情况是公司网络对某些域名有限制,换个网络环境验证一下。这个报错和代码无关,别去改openai库的源码。
Error reading choices / list index out of range。这个报错说明响应体里没有choices字段,或者choices是空数组。常见原因有三个:一是模型 ID 写错,服务端返回了错误 JSON,但代码直接去取choices[0]就崩了;二是流式模式下某些 chunk 的delta为空,你没做判空;三是请求被限流,返回了错误结构。解决办法是在解析前先判空:
for chunk in resp: if not chunk.choices: continue delta = chunk.choices[0].delta if delta and delta.content: ...非流式同理,取resp.choices[0].message.content前先确认resp.choices非空。这个判空能挡掉一大半诡异崩溃。
OAuth / authentication 相关报错。如果你用的是 Claude Code 或某些 CLI 工具,它们可能默认走 OAuth 登录流程,而不是 API Key。这时候要在配置里显式指定用 API Key 模式,把 Base URL 和 Key 填进对应的配置文件。Claude Code 的配置方式看文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。核心还是三件套:Base URL、Key、Model ID,三个都对就不会报认证错。
流式输出卡住不结束。检查是不是在for chunk in resp循环里做了耗时操作,比如每收一个字就重建整个对话窗口。正确做法是累积文本,定时刷新 UI。另外确认stream=True时没有在循环外提前return。
中文乱码。Windows 下 Tkinter 默认字体对中文支持还行,但如果气泡里出现方块,把字体显式设成("微软雅黑", 10)。文件读写统一用encoding="utf-8",SQLite 存中文没问题,但json.dumps时加ensure_ascii=False才不会变成\uXXXX。
工具调用不触发。如果你接了工具调用(function calling),模型不调工具通常是提示词没写清楚,或者工具描述太模糊。把每个工具的名称、参数、用途写具体,系统提示词里明确说“需要读写文件时调用对应工具”。另外确认你用的模型支持 function calling,不是所有模型都支持。
这些坑踩完,桌宠基本就稳了。最后说下怎么把它用起来。
6. 把桌宠用起来:从对话到工具调用的下一步
代码跑通、报错排完,接下来是让它真正帮你干活。桌宠的价值不在于“有个宠物”,而在于它把 LLM 能力塞进了你每天都会看到的桌面角落,随手就能用。
最基础的用法是对话。右键点宠物,打开对话窗口,问它问题。多轮上下文靠messages列表维护,每轮把用户和助手消息都追加进去。历史会话存 SQLite,agent_sessions存会话元信息,agent_messages存每条消息,切换会话时按session_id查出来重新渲染。消息多了要懒加载,一次只渲染最近 5 条,往上滚再加载更早的,不然窗口会卡。
进阶用法是工具调用。给模型注册几个工具:读文件、写文件、列目录、执行白名单命令、打开文件。模型判断需要时返回tool_calls,你在代码里执行对应函数,把结果作为role: "tool"的消息再发回去,模型据此生成最终回复。安全上做三层控制:路径白名单只允许访问桌面、文档、下载和项目目录;Shell 命令白名单只放行ipconfig、ping、dir这类只读命令;未授权操作弹窗让用户确认。这样即使模型判断失误,也伤不到系统。
再进一步是记忆。对话结束后让模型检查这轮有没有值得记住的偏好,比如“用户喜欢喝美式”“用户在做 Python 项目”,存进personal_memories表,字段包括 key、value、topic、keywords、importance。下次新建对话时,把 importance 大于等于 3 的记忆拼进系统提示词,宠物就“记得”你了。记忆检索用关键词 LIKE 加向量语义搜索双轨,关键词命中快,向量补语义,效果比单一路径好。
如果你想让桌宠调用外部工具或 MCP 服务器,配置入口在文档里:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。MCP 本质是标准化的工具协议,桌宠作为客户端连上去,就能用别人写好的工具。注意别把 MCP 直连到生产数据库,测试环境先跑通再说。
长期跑编码或 Agent 任务的话,Coding Plan 比按量计费更划算,入口:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。只是偶尔聊聊天、生成点小内容,用普通 Key 就够了。
最后给个实用建议:桌宠项目别一上来就堆功能。先把透明窗口、拖拽、气泡、单轮对话跑通,这四样是骨架。骨架稳了,再往上加任务看板、答题、记忆、工具调用。我见过太多人卡在“想做的功能太多,结果一个都没做完”。先让它能聊天,再让它能干活,最后让它记得你。