1. 项目缘起与整体设计思路
1.1 为什么我要从零手搓一个个人AI助手
市面上的AI助手产品已经多到用不过来了,从通用对话工具到各种垂直场景的智能体,几乎每隔几天就有新东西冒出来。但我自己用下来,总觉得少了点什么——要么是功能太封闭,想接自己的数据源接不进去;要么是黑盒太重,想调个参数、换个模型都得看平台脸色;要么就是功能堆得太杂,真正用得上的没几个,反而把响应速度和稳定性拖垮了。
所以我决定自己动手,从最底层开始搭一个完全属于我自己的个人AI助手。这个系列我会完整记录从项目初始化到逐步长出各种能力的全过程,今天是第一篇,聚焦在两件事上:项目初始化和基础Model I/O。
说白了,这一阶段的目标非常朴素——让一个空壳项目能跑起来,能跟大模型完成一次完整的“输入-输出”对话。听起来简单,但这一步是整个Agent的地基,地基没打好,后面接工具、做记忆、搞多轮编排全是空中楼阁。
这篇文章适合谁看?如果你写过一点代码,对AI助手感兴趣,想搞明白一个Agent从零到一到底要经历哪些环节,那这篇就是写给你的。我会把每一步的选择理由、踩过的坑、实测有效的参数都摊开讲,你照着抄作业就能跑通。
1.2 阶段一的核心目标拆解
在动手之前,我先把这个阶段要做的事情拆清楚。很多人一上来就想着“我要做一个能帮我干活的AI”,结果代码写了三千行,连一次正常的模型调用都没跑通。我给自己定的阶段一目标只有三条:
- 项目骨架搭起来:目录结构清晰,依赖管理规范,配置文件独立,后续加功能不用大改。
- 模型调用链路打通:能通过代码向大模型发送一条消息,并拿到完整的回复。
- 基础I/O封装完成:把“输入处理”和“输出解析”做成可复用的模块,而不是散落在各处的裸调用。
这三条看起来平平无奇,但每一条背后都有讲究。比如项目骨架,我见过太多人把所有逻辑塞进一个文件,等到要加第二个模型、第三个工具的时候,改一处崩三处。再比如I/O封装,如果不做,后面每接一个新模型就要重写一遍请求逻辑,维护成本会指数级上升。
我的经验是:阶段一宁可慢一点,把接口抽象做扎实,后面加功能就是搭积木;阶段一图快,后面就是拆炸弹。
1.3 技术选型背后的取舍逻辑
技术选型这块我纠结了挺久,最后定下来的方案是:Python作为主语言,官方SDK作为模型调用层,配置文件用YAML,环境变量管理用dotenv。逐个说下理由。
选Python没什么好争的,AI生态里Python的库最全,从模型SDK到数据处理到后续要用的向量检索,全都有成熟方案。虽然有人会说TypeScript在工程化上更舒服,但考虑到后续要接的各种AI能力,Python的生态优势太明显了。
模型调用层我直接用官方SDK,没有自己手写HTTP请求。原因很简单:官方SDK帮你处理了鉴权、重试、流式解析这些脏活,你自己写一遍除了感动自己没有任何收益。而且SDK的版本迭代会跟着API走,省得API变了你还不知道。
配置文件用YAML而不是JSON,是因为YAML支持注释,可读性好太多。你想想,半年后你回来看自己的配置,JSON里一堆键值对没有任何说明,YAML里写一行注释就能救命。环境变量用dotenv管理,是为了把密钥和配置分离,代码可以随便分享,密钥永远不进版本库。
这套选型不是唯一解,但对我来说是上手快、维护省心、扩展方便的平衡点。你可以根据自己的习惯调整,但核心原则不变:让配置和代码分离,让调用逻辑和业务逻辑分离。
2. 项目初始化实操全流程
2.1 目录结构设计与创建
目录结构这东西,一开始觉得无所谓,等项目长到几千行的时候就知道痛了。我用的结构是这样的:
personal-agent/ ├── config/ │ ├── settings.yaml │ └── .env.example ├── src/ │ ├── core/ │ │ ├── __init__.py │ │ ├── model_client.py │ │ └── io_handler.py │ ├── utils/ │ │ ├── __init__.py │ │ └── logger.py │ └── main.py ├── tests/ │ └── test_model_io.py ├── requirements.txt └── README.md逐个解释下每个目录的职责。config/放所有配置文件和密钥模板,.env.example是给协作者看的模板,真正的.env会被gitignore掉。src/core/放核心逻辑,model_client.py负责模型调用,io_handler.py负责输入输出处理。src/utils/放通用工具,比如日志。tests/放测试,阶段一至少要有一次完整的调用测试。
为什么要把core和utils分开?因为核心逻辑是跟业务强相关的,工具是跟业务无关的。后面你要换日志库、加缓存工具,只动utils就行,不会碰到核心逻辑。这种边界感在项目初期就要建立。
创建命令很简单,我习惯用一行命令搞定:
mkdir -p personal-agent/{config,src/core,src/utils,tests} && cd personal-agent然后手动创建各个文件。别小看这一步,很多人项目初始化就是随手mkdir,结果目录结构乱七八糟,后面找文件全靠搜索。
2.2 依赖管理与环境隔离
依赖管理我只做两件事:用虚拟环境隔离,用requirements.txt锁定版本。虚拟环境是必须的,不然你系统里的各种包版本冲突会让你怀疑人生。我习惯用venv,轻量、标准库自带:
python -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate激活之后,装依赖。阶段一需要的依赖不多:
pip install openai python-dotenv pyyaml这里说明一下,openai这个包虽然是某家公司的名字,但它的SDK已经成了行业里调用兼容接口的事实标准,很多模型服务都兼容这套接口。python-dotenv负责读.env文件,pyyaml负责解析YAML配置。
装完之后一定要冻结版本:
pip freeze > requirements.txt注意:不要偷懒不冻结版本。我踩过的坑是,本地跑得好好的,换台机器装了一堆新版本包,接口变了直接报错。冻结版本是给自己留后路。
2.3 配置文件与环境变量分离
配置文件我分成两层:settings.yaml放非敏感的配置,比如模型名称、超时时间、重试次数;.env放敏感信息,比如API密钥。这样代码可以放心分享,密钥永远不会泄露。
settings.yaml长这样:
model: name: "gpt-4o-mini" temperature: 0.7 max_tokens: 2048 timeout: 30 max_retries: 3 io: max_input_length: 8000 trim_strategy: "tail" log_level: "INFO"每个参数都有讲究。temperature设0.7是因为个人助手既要有点创造力又不能太飘,0.7是个平衡点。max_tokens设2048是因为阶段一只是对话,不需要长篇输出,设太大浪费额度。timeout设30秒是实测下来比较稳的值,太短容易误杀,太长卡着难受。max_retries设3次是网络抖动时的兜底。
.env.example长这样:
MODEL_API_KEY=your_api_key_here MODEL_BASE_URL=https://api.example.com/v1真正的.env里填真实值,并且一定要把.env加进.gitignore。这一步我强调一百遍都不为过,密钥泄露的事故我见过太多了。
2.4 日志模块的提前布局
日志这东西,项目小的时候觉得多余,项目大了没它寸步难行。我在阶段一就把日志模块建好,用的是Python标准库的logging,不引入额外依赖。
import logging import sys def setup_logger(name: str, level: str = "INFO") -> logging.Logger: logger = logging.getLogger(name) logger.setLevel(getattr(logging, level.upper())) if not logger.handlers: handler = logging.StreamHandler(sys.stdout) formatter = logging.Formatter( "%(asctime)s | %(name)s | %(levelname)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) handler.setFormatter(formatter) logger.addHandler(handler) return logger格式里带上时间、模块名、级别、消息,排查问题时一眼就能定位。为什么不加文件handler?阶段一先输出到控制台就够了,等后面需要持久化再加,避免一开始就搞复杂。
3. 基础Model I/O的核心实现
3.1 模型客户端封装的设计考量
模型客户端是整个助手的咽喉,所有跟大模型的交互都要经过它。我见过两种极端写法:一种是把调用逻辑散落在各处,每个功能模块自己调一遍;另一种是封装得太重,加个参数要改五个文件。我走的是中间路线:一个轻量的客户端类,对外暴露简洁的方法,对内处理重试、超时、日志。
为什么用类而不是函数?因为客户端需要持有配置(模型名、超时、重试次数),用类可以把这些状态封装起来,调用方不用每次都传一遍。而且后面要加流式输出、多模型切换,类的扩展性更好。
核心设计原则有三条:配置注入而非硬编码、异常统一处理、日志全程覆盖。配置从settings.yaml读进来注入,不在代码里写死;所有异常捕获后转成统一的错误类型,调用方不用关心底层是网络错误还是鉴权错误;每次调用前后都打日志,出问题能追溯。
3.2 输入处理模块的实现细节
输入处理看起来简单,其实坑不少。用户输入可能超长、可能为空、可能带一堆特殊字符。我的io_handler.py里做了三件事:长度校验、超长截断、空输入兜底。
长度校验的逻辑是:先算token数还是先算字符数?阶段一我用字符数近似,因为精确算token需要引入tokenizer库,增加依赖。字符数按经验大概是token数的3到4倍(中文场景),所以max_input_length设8000字符,对应大概2000到2600个token,留足余量。
超长截断策略我选了tail,也就是保留尾部。为什么?因为对话场景里,用户最新的意图通常在最后,前面的历史可以适当舍弃。如果是文档摘要场景,那就该保留头部,所以这个策略要做成可配置的。
def process_input(text: str, max_length: int, strategy: str = "tail") -> str: if not text or not text.strip(): raise ValueError("输入不能为空") text = text.strip() if len(text) <= max_length: return text if strategy == "tail": return text[-max_length:] elif strategy == "head": return text[:max_length] else: return text[:max_length]空输入直接抛异常而不是返回空字符串,是为了让调用方明确知道出了问题,而不是稀里糊涂发一个空请求出去浪费额度。
3.3 输出解析与结构化处理
模型返回的内容不能直接扔给用户,得先解析。阶段一我处理三件事:提取正文、统计用量、记录原始响应。
提取正文是因为SDK返回的是一个对象,里面嵌套了好几层,直接打印出来一堆元数据,用户看不懂。统计用量是为了监控成本,每次调用消耗了多少token心里要有数。记录原始响应是为了排查问题,有时候模型返回的内容不对劲,得回看原始数据。
def parse_output(response) -> dict: content = response.choices[0].message.content usage = { "prompt_tokens": response.usage.prompt_tokens, "completion_tokens": response.usage.completion_tokens, "total_tokens": response.usage.total_tokens } return { "content": content, "usage": usage, "raw": response }返回字典而不是直接返回字符串,是为了给调用方更多信息。调用方可以只用content,也可以拿usage做统计,灵活性更好。
3.4 完整调用链路的串联
把上面几块拼起来,一次完整的调用流程是这样的:
- 从配置文件加载设置
- 从环境变量读取密钥
- 初始化模型客户端
- 处理用户输入(校验、截断)
- 调用模型
- 解析输出
- 记录日志
main.py里串起来大概是这样:
from src.core.model_client import ModelClient from src.core.io_handler import process_input, parse_output from src.utils.logger import setup_logger logger = setup_logger("main") def chat(user_input: str) -> str: client = ModelClient() processed = process_input(user_input, max_length=8000) logger.info(f"输入长度: {len(processed)}") response = client.complete(processed) result = parse_output(response) logger.info(f"用量: {result['usage']}") return result["content"] if __name__ == "__main__": reply = chat("你好,介绍一下你自己") print(reply)跑通这一步,阶段一的核心目标就达成了。别小看这几十行代码,它把配置、日志、输入处理、模型调用、输出解析全串起来了,后面加功能就是在这个骨架上长肉。
4. 常见问题与排查技巧实录
4.1 调用失败的典型原因速查
阶段一最容易卡在调用失败上,我把遇到过的和听说过的典型问题整理成表:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 401错误 | 密钥无效或未加载 | 检查.env是否被读取,密钥是否有多余空格 |
| 404错误 | 接口地址错误 | 检查base_url是否完整,末尾斜杠是否多余 |
| 超时 | 网络问题或超时设置太短 | 加大timeout,检查网络连通性 |
| 返回空内容 | 输入被截断成空或模型拒答 | 打印处理后的输入,检查截断逻辑 |
| 额度不足 | 账户余额或配额用完 | 查看账户后台用量 |
这张表我建议贴在显示器边上,出问题先对照一遍,能省下大量瞎猜的时间。
4.2 配置加载的隐蔽陷阱
配置加载这块我踩过两个坑,都是那种“代码看着没问题但就是跑不通”的类型。
第一个坑是相对路径问题。settings.yaml用相对路径读,在项目根目录跑没问题,换个目录跑就找不到文件。解决办法是用pathlib基于文件位置算绝对路径:
from pathlib import Path CONFIG_PATH = Path(__file__).parent.parent / "config" / "settings.yaml"第二个坑是环境变量加载时机。load_dotenv()必须在读取环境变量之前调用,而且默认只找当前目录的.env。如果.env在别的位置,要显式传路径。我习惯在程序入口最顶部就调用load_dotenv(),避免后面忘了。
4.3 超长输入的截断策略选择
截断策略选错了,用户体验会很差。我实测下来,对话场景用tail(保留尾部)最合理,因为用户最新说的话最重要。但有个例外:如果用户一次性粘贴了一大段文档让你总结,那保留尾部就错了,应该保留头部或者做分段处理。
所以我的建议是:把截断策略做成配置项,不同场景用不同策略。阶段一先支持head和tail两种,后面需要更复杂的策略(比如按段落截断、按语义截断)再扩展。不要一开始就追求完美,够用就行。
实操心得:截断的时候在日志里记一笔“输入被截断,原长度X,截断后Y”,这样出问题能快速定位是不是截断导致的。
4.4 日志与调试的实用技巧
日志级别要会用。开发阶段用DEBUG,能看到每次请求的完整内容;上线后切INFO,只记录关键节点。我见过有人一直用DEBUG,日志文件一天涨几个G,排查问题时反而被淹没。
调试模型调用的时候,我有个小技巧:把原始响应序列化后存一份到本地文件。因为模型返回的对象有时候结构复杂,直接打印看不全,存成JSON慢慢看更清楚。但要注意,如果响应里包含敏感信息,存之前要脱敏。
import json with open("debug_response.json", "w", encoding="utf-8") as f: json.dump(response.model_dump(), f, ensure_ascii=False, indent=2)这个文件记得加进.gitignore,别不小心提交上去。
5. 阶段一的经验沉淀与后续扩展方向
5.1 我踩过的三个真实坑
第一个坑是密钥硬编码。最开始图省事,直接把密钥写在代码里,后来要分享代码给别人看,临时删密钥删得手忙脚乱。从那以后我所有项目一律用.env,代码里永远不出现真实密钥。
第二个坑是异常吞掉。早期我写try-except的时候习惯性except Exception: pass,结果调用失败了一点提示都没有,排查了半天才发现是密钥没加载。现在我至少会logger.error记一笔,绝不静默失败。
第三个坑是配置和代码耦合。一开始模型名写死在代码里,想换个模型要改代码重新部署。后来改成从配置读,改个YAML文件重启就行,效率高太多。
这三个坑的共同点是:都是图省事导致的。阶段一多花十分钟做规范,后面能省十个小时排查。
5.2 阶段一到阶段二的衔接准备
阶段一跑通之后,阶段二我打算做这几件事:多轮对话记忆、流式输出、多模型切换。为了平滑过渡,阶段一的代码要预留好扩展点。
多轮对话记忆需要客户端支持传入消息列表而不是单条消息,所以complete方法的签名要设计成接受列表。流式输出需要客户端支持回调或者生成器,所以调用层要抽象出统一的接口。多模型切换需要配置支持多套模型参数,所以settings.yaml的结构要提前规划。
这些扩展点不用现在就实现,但接口设计的时候要想到。比如complete方法,我现在就写成接受消息列表,虽然阶段一只传一条,但后面加记忆的时候不用改签名。
5.3 给同样在起步的人几句实在话
做个人AI助手这件事,最大的门槛不是技术,是耐心。你会遇到各种莫名其妙的报错,会发现自己对某些概念的理解是错的,会在配置上反复折腾。这些都是正常的,每个从零开始的人都经历过。
我的建议是:先把最小闭环跑通,再谈优化。不要一上来就想着架构多优雅、功能多全面,先让一次对话能跑起来,哪怕代码丑一点。跑通之后你才有资格谈优化,没跑通之前所有的设计都是纸上谈兵。
还有就是多打日志。模型调用是个黑盒,你看不到里面发生了什么,日志是你唯一的眼睛。宁可日志多一点,也不要出问题的时候两眼一抹黑。
最后,把每一步都记下来。我现在写的这个系列,就是把我踩过的坑、做过的选择都记下来。过几个月回头看,这些记录比任何教程都有价值,因为它们是针对我自己的项目、我自己的场景的。你也可以这么做,哪怕只是写个简单的笔记。