从零搭建个人AI助手:项目初始化与Model I/O实战

📅 发布时间:2026/10/12 12:43:02
从零搭建个人AI助手:项目初始化与Model I/O实战
Agent实践系列个人AI助手——阶段1项目初始化与Model I/O落地这期是这个系列的第七篇。前面聊了好几轮Agent的思路和框架后台也有不少人催更问什么时候能跑一个完整项目出来。这次我直接给自己定了目标——做一个个人AI助手不是玩具是真正能持续迭代、以后要接记忆、接工具、接多轮对话的那种产品级Demo。既然是开篇这一篇先把两件最核心的事讲透项目怎么初始化、基础的Model I/O怎么设计。如果你也想照着搭一个或者你正卡在框架文档看了一堆手底下一个文件都还没建的阶段那这篇文章就是给你写的。1. 整体设计思路与选型判断1.1 这个阶段要解决的问题个人AI助手这个项目往大了说可以无限做加法。但我不打算一上来就把架构铺得天花板那么高。阶段1只聚焦三件事项目骨架目录结构、虚拟环境、配置文件、依赖管理这些决定了后续能不能干净地加功能。模型接入让代码能稳定地调起大模型并且对不同厂商的接口有一定的适配能力。输入输出规范化不光是打印字符串而是把用户输入→提示词→模型返回→结构化结果这条链路做成可复用的组件。说白了就是把地基打牢。很多项目死在半路上的原因不是功能不够多而是前期就没有想清楚基础的调用链怎么组织到后面代码全是补丁摞补丁。1.2 为什么选择当前这套技术组合在框架选型上我几乎没犹豫就选了LangChain。原因很简单后续的个人AI助手要做到对话管理、工具调用、记忆持久化这些LangChain都有成体系的生态。虽然2023到2024年之间社区里关于LangChain的争议一直没停过但对比下来它的组件抽象度对于个人项目来说依然是最省力的。模型这边我选择的是走OpenAI兼容接口协议。这不代表我只用某一家模型而是因为现在绝大多数模型服务都提供了兼容接口——不管是官方的还是第三方的网关服务一套代码就能切换模型对前期项目起步阶段来说非常友好。# 记住这个原则面向协议编程而不是面向某家厂商编程Python版本我用了3.11不要用3.12某些依赖的编译在3.12上还是有小坑。这一点在后面常见问题部分会展开讲。1.3 分阶段规划的视角这个项目我计划分四个阶段走阶段1本篇项目初始化 基础Model I/O阶段2对话记忆 多轮上下文管理阶段3工具调用 Agent行为编排阶段4交互界面 部署本篇先做第一阶段。但我设计的时候会把后面几阶段的扩展点提前预留好——比如配置文件的参数命名、工具注册表的目录位置这些现在多花五分钟以后省两小时。2. 项目初始化实操全记录2.1 目录结构设计我见过很多人初始化项目的时候直接pip install langchain然后创建一个main.py就开写。这种开始方式写Demo没问题但要是打算持续迭代后面必然有一步重构逃不掉。我这次的目录结构是这样的personal-ai-assistant/ ├── .env.example # 环境变量模板 ├── .env # 本地环境变量不入库 ├── .gitignore ├── pyproject.toml # 项目依赖 ├── README.md ├── app/ │ ├── __init__.py │ ├── config.py # 配置管理 │ ├── main.py # 入口 │ ├── models/ # 模型I/O层 │ │ ├── __init__.py │ │ ├── llm.py # 模型封装 │ │ ├── prompts.py # 提示词模板 │ │ └── parsers.py # 输出解析器 │ └── utils/ │ ├── __init__.py │ └── logger.py # 日志工具 └── tests/ ├── __init__.py └── test_model_io.py说一下为什么这样分models/这个目录很多人会直接叫core/或者services/但我特意叫models是因为这一层放的就是和模型直接交互的东西。以后阶段2加memory/阶段3加tools/跟models/是平级的兄弟模块。app/config.py单独拿出来而不是直接在main.py里读环境变量。配置文件被多个模块引用独立成文件是基本操作。tests/从第一天就建好。阶段1不写测试的话你以后大概率永远都不会补。2.2 依赖管理与环境准备用pyproject.toml而不是requirements.txt。推荐用uv来管理虽然刚开始上手有一点学习成本但体验比pip丝滑太多。[project] name personal-ai-assistant version 0.1.0 description Personal AI Assistant - Phase 1 requires-python 3.11 dependencies [ langchain0.2.0, langchain-openai0.1.0, pydantic2.6.0, python-dotenv1.0.0, rich13.7.0, ]其中rich是我个人的私心推荐用来做命令行输出格式化比print好看太多而且零学习成本。如果用的是uvuv init personal-ai-assistant cd personal-ai-assistant uv add langchain langchain-openai pydantic python-dotenv rich实测下来这套组合没有什么依赖冲突。唯一要注意的是版本号我这些版本是验证过的如果你要用pip安装请务必锁定大版本不要直接装最新版——这个行业里依赖更新往往伴随着破坏性变更。2.3 配置文件管理.env.example长这样# Model Provider LLM_PROVIDERopenai_compatible LLM_MODEL_NAMEgpt-4o-mini LLM_API_KEYsk-your-key-here LLM_BASE_URLhttps://api.openai.com/v1 LLM_TEMPERATURE0.7 LLM_MAX_TOKENS4096 LLM_TIMEOUT60 # App Settings APP_LOG_LEVELINFO APP_LOG_FILElogs/app.log这里有一个容易被新手忽略的设计细节我保留了LLM_BASE_URL这个配置项而且没有写死。因为后面不管是切换模型服务商还是本地部署模型服务改配置就能切不用改代码。这就是前面说的面向协议编程的落地方式。config.py用pydantic-settings来做配置校验from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config SettingsConfigDict( env_file.env, env_file_encodingutf-8, extraignore, ) # Model llm_provider: str openai_compatible llm_model_name: str gpt-4o-mini llm_api_key: str llm_base_url: str https://api.openai.com/v1 llm_temperature: float 0.7 llm_max_tokens: int 4096 llm_timeout: int 60 # App app_log_level: str INFO app_log_file: str logs/app.log settings Settings()为什么用pydantic-settings而不是直接os.getenv因为它会自动从.env读值并做类型转换。llm_temperature如果用os.getenv拿到的是字符串0.7你还得手动float()。这种细节积少成多框架帮你做掉才是省力气的做法。注意一个坑extraignore这个参数必须写否则.env里多一个未定义的键就会直接抛异常。2.4 Git初始化项目文件建好之后记得第一时间git init并提交。不是走形式而是当你后面写代码写乱了想回滚的时候你会感谢当初这个五分钟不到的操作。gitignore至少包含这些.env logs/ __pycache__/ *.pyc .venv/ .pytest_cache/logs/目录直接一起忽略了运行时自动创建。3. 基础Model I/O核心实现Model I/O 是LangChain框架三大核心组件之一另外两个是Memory和Agent后面阶段会用到。它包含三个子模块Prompt、Model、Output Parser。我直接按这三个维度来讲代码实现。每个环节讲清楚代码背后解决了什么问题而不是只贴代码。3.1 模型封装层llm.py模型封装是整个阶段的中心点。不管以后是换了模型、加了重试还是接入了多模型路由都是从这一层改起。from langchain_openai import ChatOpenAI from langchain_core.language_models import BaseChatModel from ..config import settings def create_llm() - BaseChatModel: 创建统一的大模型实例 return ChatOpenAI( modelsettings.llm_model_name, api_keysettings.llm_api_key, base_urlsettings.llm_base_url, temperaturesettings.llm_temperature, max_tokenssettings.llm_max_tokens, timeoutsettings.llm_timeout, )这里用ChatOpenAI而不是OpenAI关键在于ChatOpenAI返回的是BaseChatModel类型天然支持后续要做的一系列链式调用。它会自动把模型响应包成AIMessage对象而不是光秃秃的字符串——这个抽象在后面阶段会显得特别有用。注意ChatOpenAI的timeout参数单位是秒。我习惯设60秒碰到长输出的时候不至于把一个请求卡死半小时。如果一个项目里需要同时接多个模型怎么办比如助手主模型用A家的摘要模型用B家的。我建议在create_llm()上再加一层工厂函数用LLM_PROVIDER来决定实例化哪个类。def create_llm_by_provider(provider: str None) - BaseChatModel: provider provider or settings.llm_provider if provider openai_compatible: return create_llm() elif provider anthropic: from langchain_anthropic import ChatAnthropic return ChatAnthropic( modelsettings.llm_model_name, api_keysettings.llm_api_key, ) else: raise ValueError(f未知的模型提供方: {provider})现阶段不要过度设计但这一层工厂模式建议先留着成本就十分钟后面换模型不用去每个调用点改代码。3.2 Prompt模板prompts.py个人AI助手的第一版提示词目标不是让它像一个专家而是让它成为一个可控的对话者。我设计了两套Prompt一套是通用助手的系统提示词一套是用于特定任务比如提炼总结的模板。from langchain_core.prompts import ChatPromptTemplate from langchain_core.prompts import MessagesPlaceholder # 通用助手系统提示词 SYSTEM_PROMPT 你是一个高效、友好的个人AI助手。 你的定位是帮助用户解决问题而不是替代用户思考。回答时请注意 1. 优先给出可以直接执行的方案不要绕弯子 2. 如果信息不充分主动询问不要猜 3. 回答用中文专业名词可以保留英文 4. 代码、命令等示例需要用代码块展示 5. 对于用户提出的要求直接输出结果不要添加关于作为AI助手的免责声明 # 基础对话模板 general_prompt ChatPromptTemplate.from_messages( [ (system, SYSTEM_PROMPT), MessagesPlaceholder(variable_namehistory), (human, {input}), ] ) # 任务型Prompt模板 task_prompt ChatPromptTemplate.from_messages( [ (system, SYSTEEM_PROMPT), ( system, 当前任务{task_description}。请严格按照要求执行不要偏离任务范围。, ), (human, {input}), ] )这里有几个关键设计决策第一用MessagesPlaceholder而不是直接拼接历史消息字符串。这个位置是给阶段2的记忆模块预留的——到时候把历史消息列表填进去就行模板不用改。第二任务型Prompt和通用Prompt分开但不拆分文件。都在prompts.py里维护。因为目前规模还不大拆太碎反而增加翻文件的成本。第三系统提示词里明确写了不要添加关于作为AI助手的免责声明——实测下来很多模型默认会带这一句又啰嗦又影响体验。通过提示词约束能有效减少这种废话。有读者可能会问直接用ChatPromptTemplate会不会限制我写更复杂的Prompt答案是不会。ChatPromptTemplate支持任意多段消息组合system、human、ai、tool 消息都可以混排灵活性足够覆盖绝大多数场景。如果你后面要用AgentLangChain官方模板里同样推荐用它来定义Agent的推理模板。3.3 输出解析器parsers.py这一节是整个阶段1里最有含金量的部分。写LLM应用最讨厌的就是模型返回不稳定的格式。你今天让模型返回JSON它给你格式完整明天同一个问题它可能就在JSON外面包了一层块。所以我直接用PydanticOutputParser来强制模型按结构输出。思路很简单定义好响应数据的结构用Pydantic模型解析器会把这个结构描述自动注入到Prompt里然后在模型返回结果时做校验和解析。from typing import List, Optional from pydantic import BaseModel, Field from langchain_core.output_parsers import PydanticOutputParser # 定义结构化输出格式 class AIAssistantResponse(BaseModel): AI助手回复的结构化格式 reply: str Field(description给用户的最终回复内容) related_topics: List[str] Field( default_factorylist, description与本次对话相关的主题词最多5个, ) needs_clarification: bool Field( description信息是否充分是否需要向用户追问, ) suggested_questions: Optional[List[str]] Field( defaultNone, description如果需要追问给出2-3个参考问题否则为null, ) # 创建解析器 assistant_parser PydanticOutputParser(pydantic_objectAIAssistantResponse) # 获取格式指令 FORMAT_INSTRUCTIONS assistant_parser.get_format_instructions()get_format_instructions()返回的是一段很长的JSON Schema指令它会告诉模型你只能返回符合这个结构的JSON。有了这个模型的输出格式稳定性会从20%左右直接拉到95%以上。但只把format_instructions塞进Prompt还不够因为模型偶尔还是会犯错。实际使用时我包裹一个安全解析函数from langchain_core.output_parsers import OutputParserException import json import re def safe_parse_ai_response(text: str) - AIAssistantResponse: 安全解析模型输出带异常兜底 try: return assistant_parser.parse(text) except OutputParserException: # 尝试提取文本中的JSON块兼容被code block包裹的情况 json_match re.search(rjson\s*\n(.*?)\n, text, re.DOTALL) if json_match: try: data json.loads(json_match.group(1)) return AIAssistantResponse(**data) except Exception: pass # 最后的兜底强制装填只保留原始的文本回复 return AIAssistantResponse( replytext.strip(), related_topics[], needs_clarificationFalse, suggested_questionsNone, )多说一句这里related_topics、needs_clarification这些字段不是拍脑袋定的。它们是给阶段4做界面交互时预留的界面上可以展示相关主题推荐、追问引导按钮。如果你确定自己的场景根本用不到这些删掉一些字段完全没问题但至少加一个结构化字段哪怕它只是描述性的元信息——它会逼迫模型的输出逻辑变得规整。第三点兜底策略里我是宁可丢弃结构化信息也不要把整段回复扔掉。用户永远应该拿到模型的回复内容格式是次要的。这在真实产品里是铁律。3.4 串联跑通main.py有了上面三个组件入口文件就非常简单了。串联逻辑是Prompt Template → Model → Output Parser。from rich.console import Console from rich.panel import Panel from .models.llm import create_llm from .models.prompts import general_prompt, SYSTEM_PROMPT from .models.parsers import safe_parse_ai_response, FORMAT_INSTRUCTIONS console Console() llm create_llm() def chat_once(user_input: str) - None: # 1. 构建带格式约束的Prompt prompt_value general_prompt.invoke( { history: [], input: user_input, # 在某一个message里注入format指令 } ) # 2. 模型调用 # 这里的messages最后一条加上格式要求 messages prompt_value.to_messages() messages[-1].content f\n\n请确保输出采用下面的格式\n{FORMAT_INSTRUCTIONS} response llm.invoke(messages) # 3. 解析输出 parsed safe_parse_ai_response(response.content) # 4. 展示 console.print(Panel(parsed.reply, titleAI助手, border_styleblue)) if parsed.needs_clarification and parsed.suggested_questions: console.print([yellow]补充询问[/yellow]) for q in parsed.suggested_questions: console.print(f - {q}) console.print(f[dim]相关主题{, .join(parsed.related_topics)}[/dim])看到上面的写法你可能会有一个疑问为什么把FORMAT_INSTRUCTIONS注入到最后的human消息而不是直接放到system提示词里我踩过的坑是某些模型对system消息中的JSON Schema格式要求遵守得并不好但把它作为user消息末尾的显式约束遵守率会显著上升。这不算正规的做法但实测有效。LLM应用就是经验学科能在实际中跑通就是好方案。main函数加一个简单的交互循环def main(): console.print([bold]个人AI助手 v0.1 - 阶段1 Model I/O 已就绪[/bold]) console.print([dim]输入 exit 退出clear 清空上下文[/dim]\n) while True: try: user_input input(你 ).strip() except (KeyboardInterrupt, EOFError): console.print(\n[dim]再见[/dim]) break if user_input.lower() in (exit, quit): console.print([dim]再见[/dim]) break if user_input.lower() clear: console.print([dim]上下文已重置阶段2后再支持真正的记忆[/dim]) continue if not user_input: continue chat_once(user_input) if __name__ __main__: main()跑起来效果就是这样一个交互式的命令行助手已经能说话了。虽然还没有记忆功能但你可以感受到模型I/O链路已经通了。4. 常见问题与排查技巧实录这个部分我把最初踩过的坑按现象→原因→解决的方式整理成速查表希望能帮你省掉几个晚上的排查时间。4.1 API Key相关的坑现象原因解决代码报401.env文件没被加载确保.env在项目根目录且config.py中env_file.env路径正确报403Key 没有对应模型的权限检查模型服务平台上Key的权限范围一直转圈后超时base_url是https://开头但缺少/v1路径确认拼接后的URL能直接访问到chat completions接口本地设置了OPENAI_API_KEY环境变量导致代码忽略.env环境变量优先级比.env高检查当前shell环境变量用unset OPENAI_API_KEY清掉第一个坑最隐蔽pydantic-settings默认.env文件里的变量不会覆盖已有的系统环境变量。如果你之前在shell里export OPENAI_API_KEY过那么.env里怎么改都没用。排查的时候第一反应print(settings.llm_api_key[:5])看看到底加载到没有。4.2 Python版本与依赖的兼容性问题我强烈建议使用pyenv或conda固定Python 3.11版本。我试过在Python 3.12下安装pydantic2.6系列某些依赖需要编译rust库在一台老机器上直接编译失败。后来切到3.11三分钟搞定。LangChain的版本锁定更是一条血泪经验不要用pip install langchain然后指望它自动给你拉一个能跑的版本。不同大版本之间的API差异极大进入2024年之后langchain和langchain-core的版本必须保持配套。我的做法是直接pip freeze之后把关键版本号写死在pyproject.toml里。pip freeze | grep -E (langchain|openai|pydantic)4.3 JSON输出解析失败的多种形态这是最让人头秃的一类问题因为模型每次失败的方式都可能不同。我归纳成三种模型输出的JSON被Markdown包裹输出是json\n{...}\n——PydanticOutputParser的parse方法在某些版本里不能处理这种带代码块的情况。解决就是safe_parse_ai_response里的正则提取。字段缺失模型没生成suggested_questions即使你在Field里写了description。解决给所有非必填字段设置defaultNone或default_factory保证解析器能字段对齐。多余的逗号JSON末尾多了个逗号或者中文字符串没转义。底线兜底就是json.loads失败以后走强制装填路线。建议测试时多跑几个不同类型的输入短问题、长问题、带代码的问题、含有JSON关键词的问题看解析器的鲁棒性。4.4 中文显示和日志编码在Windows终端上控制台输出中文乱码是一个经典问题。# 如果出现 UnicodeDecodeError 或控制台乱码 import sys sys.stdout.reconfigure(encodingutf-8)Windows下运行建议在代码入口加这个。macOS和Linux一般没这个问题。日志方面我用logging搭配RotatingFileHandler防止logs/app.log无限膨胀。这个属于老生常谈但真出问题的时候能省很多事。4.5 Prompt注入格式指令后模型反而退化的处理有的模型在收到复杂的JSON Schema之后虽然在结构上更准了但回复内容本身变呆板了、像是被格式绑架了。我的处理经验是给FORMAT_INSTRUCTIONS降级。比如不把完整的Pydantic Schema塞进去而是改成一个精简版的示例FORMAT_EXAMPLE { reply: 给用户的回复, related_topics: [], needs_clarification: false, suggested_questions: null }实测对某些模型效果反而更好。结构信息不一定越完整越有效模型能力不同你要快速试出当前模型的最佳输入形态。5. 项目进度自检与下一步规划本阶段做完我给自己列了个验收清单[x] 项目目录结构清晰配置独立成文件密钥不入库[x] 模型封装层支持切换兼容OpenAI协议的各类服务[x] Prompt模板支持带格式约束的调用[x] 输出解析器具备兜底策略异常不吞用户可见的回复[x] 命令行交互可跑通结束进程干净这一套做完我的体会是阶段1的核心价值不是能跑起来这个结果而是把以后每次写功能时都要做的那层基础交互提前打磨成了稳定的组件。后面加记忆、加工具、加UI每个阶段都只关注增量部分而不是每次都要回头补地基。最后再送一个实用技巧把safe_parse_ai_response之外再加一层LLMResponseLogger把每次请求和响应都落一份原始日志不管后面做评测还是调试有了原始数据你才能定位问题。我在做agent系列的时候这句话真的救了我好几次。这一篇讲清楚了阶段1的初始化与Model I/O下一篇我会接着做阶段2——对话记忆。届时会涉及LangChain的记忆组件选型、上下文窗口管理、多轮对话的摘要策略。等阶段3的工具调用做完这个个人AI助手的完整度就会开始超出预期了。