LLM+Function Calling开发助手Picodevil实战
之前在一次内部工具研发中,我尝试用大语言模型(LLM)搭建一个本地开发助手,项目代号取名为 Picodevil。整体目标很直接:让模型能读取本地项目文件、理解开发需求、生成代码片段,并借助工具调用完成一些简单的文件查询操作。搭建过程中踩了不少坑,从提示词设计到 Function Calling 的循环处理,从上下文长度控制到工具执行的安全性,每一步都需要重新思考。这篇文章就以 Picodevil 为例,把用 LLM 搭建一个可运行开发助手的完整思路、核心代码和常见问题整理出来,希望能给正在做 LLM 应用开发的读者一些参考。
阅读本文后,你将理解 LLM 应用的最小架构是怎样的,掌握提示词模板、工具调用、上下文管理这几块关键设计,并且可以直接获得一个基于 Python 的 CLI 版开发助手源码骨架。无论你是刚开始接触 LLM 应用开发的新手,还是已经在做 Agent 相关项目的开发者,都可以把本文的代码作为基底继续扩展。
1. 背景与核心概念
1.1 Picodevil 是什么
Picodevil 不是一个复杂的平台,它是一个运行在命令行里的轻量级开发助手。你可以通过命令行向它提问,比如“帮我看看当前项目里 main.py 的功能”,它就会先调用工具读取文件,再基于文件内容给出结构化回答;你还可以让它“生成一个读取 CSV 并统计行数的 Python 脚本”,它会直接给出代码和简要说明。
之所以叫 Picodevil,是因为这个项目最初的定位就是“小而有点叛逆”的编程小助手。Pico 代表轻量、小巧,devil 代表它不仅能回答理论问题,还能真正触碰本地文件、执行一些可控操作。从产品形态上看,它介于普通聊天机器人和完整 AI IDE 插件之间:没有图形界面,但保留了一个可以不断追加能力的工具层。
这类工具解决的典型问题包括:
- 在 IDE 和网页聊天窗口之间来回切换,上下文经常丢失。
- 让模型直接读取某段代码时,需要手动复制粘贴,效率低。
- 希望把公司内部代码库的查询、规范检查等流程沉淀成固定能力。
- 需要把 LLM 接入到现有 CI/CD 或命令行工作流中。
1.2 用 LLM 搭建开发工具时一定会遇到的概念
要动手之前,先理清几个核心概念。它们不是学术名词,而是代码里真实出现的参数和模块。
第一是 LLM API。大多数模型服务商会提供 HTTP 接口,你传入一组消息(messages)和模型参数,它返回模型生成的文本。消息数组里通常包含 system(系统设定)、user(用户输入)、assistant(模型回复)三种角色。在工具调用场景中还会多出一种 tool 角色,用于把工具执行结果回传给模型。
第二是 Token 与上下文窗口。Token 可以简单理解为模型处理文本的最小单位,中文场景下一个汉字可能对应一个或多个 Token。模型一次能处理的输入加输出总量是有限的,这个上限就是上下文窗口。Picodevil 在读取文件时必须对内容做截断,原因就在这里。
第三是 Function Calling(函数调用/工具调用)。这是让 LLM 不再局限于“聊天”的关键能力。你可以提前声明一批工具,包括工具名、描述和参数结构,模型在回答时会判断是否需要调用某个工具,并返回结构化的调用请求,包括工具名和参数 JSON。程序收到这个请求后执行真实函数,再把结果作为新的消息交给模型,模型再继续生成最终回答。
第四是 Agent 循环。所谓 Agent,简单理解就是“模型 + 工具 + 循环控制”的组合。Picodevil 的主循环就属于一个最小版 Agent:模型决定调用工具,程序执行工具,结果回传,模型继续推理,直到不再请求调用工具为止。
1.3 为什么不直接用现成的 AI 工具
市场上已经有非常多 AI 编程助手,为什么还要自己封装?我的核心理由是定制化和可控性。现成产品通常运行在别人定义的交互流程里,你很难让它读取特定目录下的配置文件、对接内部脚本文档、按照团队的代码规范生成内容。自己用 LLM API 封装之后,工具集完全由你定义,提示词可以由版本库管理,调用成本也可以按接口精确统计。
此外,自建方案的另一个优势是可以复用企业内部已有的数据和工具。比如把接口文档、数据库 Schema、代码规范文件做成检索库,再在工具调用层暴露给模型,这让 LLM 输出的内容更贴近业务实际。当然,自建也意味着网络、模型服务稳定性、安全边界等问题都要自己处理。这正是本文后续几个章节重点讨论的内容。
2. 环境准备与版本说明
2.1 运行环境
Picodevil 的示例代码使用 Python 编写,建议环境如下:
- 操作系统:Windows 10/11、macOS、Linux 均可,本文示例不依赖特定系统。
- Python 版本:建议 3.10 或更高,代码中使用了较新的类型注解和字符串写法。
- 包管理工具:pip。
- 命令行终端:系统自带终端即可。
版本需要根据你的项目实际情况调整,本文示例以常见环境为例,重点演示配置思路。不同操作系统的 Python 安装方式不同,建议使用虚拟环境隔离依赖,避免污染全局环境。
2.2 模型服务与 API Key
Picodevil 通过 OpenAI 兼容接口调用模型服务。也就是说,不管底层是云端大模型 API,还是本地部署的推理服务,只要它提供/v1/chat/completions这类兼容接口,Picodevil 的代码就可以直接对接。
你需要准备以下信息:
- base_url:模型服务的接口地址。
- api_key:访问密钥,注意不要硬编码到源码中。
- model:要使用的模型名称,以服务商实际提供为准。
不同模型对 Function Calling 的支持程度不同,建议先阅读模型服务商文档确认。对于不确定的模型,可以先写一个最小请求测试,确认能正常返回内容后再继续开发。
2.3 项目目录规划
在开始写代码前,先规划项目结构。Picodevil 最小版本只包含四个文件,目录结构如下:
picodevil/ ├── config.yaml # 模型服务和运行参数配置 ├── requirements.txt # Python 依赖 ├── picodevil/ │ ├── __init__.py # 空文件,标识包目录 │ ├── llm_client.py # LLM 客户端封装 │ ├── tools.py # 工具定义与实现 │ └── main.py # 命令行主循环这样的分工很清晰:配置和业务分离,客户端封装只负责接口通信,工具层专门管理“模型能做什么”,主循环负责把用户输入、模型输出、工具调用串起来。后续如果要扩展新工具,只需要在 tools.py 中添加函数和 Schema。
3. 核心原理拆解
3.1 一次 LLM 请求的完整链路
Picodevil 的核心请求逻辑非常简单,本质上就是调用一次聊天补全接口。先来看最基础的请求结构:
from openai import OpenAI client = OpenAI( base_url="https://api.example.com/v1", api_key="sk-xxx", ) response = client.chat.completions.create( model="your-model-name", messages=[ {"role": "system", "content": "你是一个开发助手。"}, {"role": "user", "content": "解释一下什么是函数调用。"}, ], temperature=0.2, ) print(response.choices[0].message.content)这段代码里有几个关键参数:
- messages:对话消息列表。system 消息设定模型身份和行为,user 消息是用户输入,assistant 消息通常是历史回复。
- temperature:采样温度,控制输出的随机性。代码生成场景建议设低一点,比如 0.2,让输出更稳定。
- model:模型名称,必须与服务商提供的名称完全一致。
理解这条链路很重要,因为后续所有高级能力,包括工具调用、多轮对话、上下文记忆,都是在这个基础请求之上叠加逻辑实现的。Picodevil 的 llm_client.py 做的其实就是把这段调用封装成可复用的函数。
3.2 提示词模板设计
提示词是控制 LLM 行为最直接的手段。Picodevil 的 system 提示词不追求复杂,但必须明确以下几点:
- 角色定义:你是谁,你运行在哪里。
- 能力边界:你能做什么,不能做什么。
- 输出风格:回答应该简洁还是详细,代码如何展示。
一个比较可靠的 system 提示词模板如下:
你是 Picodevil,一个运行在用户本地的开发助手。 你可以阅读项目文件、获取当前时间,并给出代码建议。 回答要简洁、准确,代码示例需要标注语言。 如果工具执行失败,请如实告知用户,不要编造工具结果。注意最后一句话:不要编造工具结果。这个问题在实际使用中非常常见,模型在工具调用失败时有时会自行补全一个看似合理的结果,因此提示词中必须明确约束。另外,提示词会随着功能迭代不断调整,建议像管理代码一样管理提示词,记录每一次改动对输出质量的影响。
3.3 工具调用:让 LLM 可以操作本地资源
工具调用是 Picodevil 的核心。它的工作流程可以拆成四步:
- 程序把工具 Schema 列表随请求一起发给模型。
- 模型判断当前问题是否需要调用工具。如果需要,返回 tool_calls 字段,里面包含工具名和参数 JSON。
- 程序解析参数,执行真实函数,拿到结果。
- 程序把工具结果作为 role=tool 的消息追加到对话中,再发给模型。
这里最关键的是工具 Schema。每个工具必须要有清晰的 name、description 和 parameters。description 尤其重要,因为模型依赖这段文字判断什么情况下该调用这个工具。比如 read_project_file 的描述是“读取指定文本文件内容,用于分析项目源码”,模型看到用户问“看看某个文件”,就会优先选择这个工具。
工具调用不一定是单轮的。模型可能先调用读取文件工具,发现文件内容不够,再调用另一个工具。所以主循环必须支持多轮工具调用,直到模型返回普通文本为止。这个循环通常要设置最大轮数,避免死循环。
3.4 上下文管理:不要一股脑塞给模型
很多初学者在开发 LLM 应用时容易忽略上下文长度限制。Picodevil 读取文件时,如果文件很大,直接全量塞进消息会导致 Token 超限。更合理的做法是:
- 对文件内容做长度截断,只保留前 N 个字符。
- 对大文件先做摘要,再把摘要发给模型。
- 对多轮对话做历史压缩,只保留最近的几条消息。
这些操作本质上都是为了在规定上下文窗口内,尽量保留有用信息。更进阶的方案是引入 RAG(检索增强生成),把项目文档切块向量化,查询时只检索相关片段,而不是把所有内容都发给模型。Picodevil 目前没有内置向量检索,但在实际项目中,RAG 是解决“模型不知道你的私有代码库”这个问题的主流方案。
4. 完整实战:Picodevil 最小可运行版本
接下来我们完整实现一个 Picodevil 最小版本。这个版本包含两个工具:获取当前时间、读取项目文件。代码可以直接复制到本地运行,唯一需要修改的是 config.yaml 中的模型服务配置。
4.1 初始化项目与依赖
首先创建项目目录和虚拟环境:
mkdir picodevil cd picodevil python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate然后创建 requirements.txt:
openai>=1.0.0 PyYAML>=6.0安装依赖:
pip install -r requirements.txt4.2 编写配置文件
在项目根目录创建 config.yaml:
# 文件路径:config.yaml llm: base_url: "https://api.example.com/v1" api_key: "sk-替换成你的密钥" model: "your-model-name" temperature: 0.2 max_tokens: 2048这里要特别提醒,config.yaml 如果包含真实 API Key,一定不要提交到 Git 仓库。建议把 config.yaml 加入.gitignore,或者使用环境变量替代 api_key 字段。
4.3 实现 LLM 客户端封装
下面创建 picodevil/llm_client.py 文件:
# 文件路径:picodevil/llm_client.py """LLM 客户端封装,统一管理模型请求。""" from openai import OpenAI def build_client(cfg): """根据配置创建 OpenAI 兼容客户端。""" return OpenAI( base_url=cfg["llm"]["base_url"], api_key=cfg["llm"]["api_key"], ) def chat(client, messages, tools=None, tool_choice="auto", cfg=None): """发送一次对话请求,支持可选的工具定义。""" cfg = cfg or {} params = { "model": cfg["llm"]["model"], "messages": messages, "temperature": cfg["llm"].get("temperature", 0.2), "max_tokens": cfg["llm"].get("max_tokens", 2048), } if tools: params["tools"] = tools params["tool_choice"] = tool_choice response = client.chat.completions.create(**params) return response.choices[0].message这段封装代码的好处是,主循环中不需要关心 SDK 细节。如果后续要增加超时、重试、日志,统一在这个文件里改即可。
4.4 注册自定义工具
下面创建 picodevil/tools.py 文件。这个文件包含两部分:工具 Schema 定义和工具具体实现。
# 文件路径:picodevil/tools.py """Picodevil 自定义工具:把本地能力暴露给 LLM。""" import os from datetime import datetime TOOL_SCHEMAS = [ { "type": "function", "function": { "name": "get_current_time", "description": "获取当前系统时间,用于回答时间相关问题。", "parameters": { "type": "object", "properties": {}, }, }, }, { "type": "function", "function": { "name": "read_project_file", "description": "读取指定文本文件内容,用于分析项目源码。", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "相对于项目根目录的文件路径", } }, "required": ["path"], }, }, }, ] def get_current_time(args): """获取当前系统时间。""" return datetime.now().strftime("%Y-%m-%d %H:%M:%S") def read_project_file(args, root="."): """读取文件,只允许访问项目根目录内的文件,避免路径穿越。""" rel_path = args.get("path", "") full_path = os.path.realpath(os.path.join(root, rel_path)) root_path = os.path.realpath(root) if not full_path.startswith(root_path): return "错误:不允许访问项目目录之外的路径。" if not os.path.isfile(full_path): return "错误:文件不存在或不是普通文件。" try: with open(full_path, "r", encoding="utf-8") as f: return f.read()[:4000] except Exception as e: return f"读取失败:{e}" TOOL_IMPL = { "get_current_time": get_current_time, "read_project_file": read_project_file, }这里有一个容易忽略的安全细节:read_project_file 使用了os.path.realpath和startswith双重校验,确保模型请求的路径必须位于项目根目录内,防止通过../../etc/passwd这类路径读取系统文件。虽然 Picodevil 是本地开发工具,但这个防护习惯建议保留,后续接入不可信输入时会非常有用。
4.5 编写主循环
下面创建 picodevil/main.py,这是 Picodevil 的入口文件:
# 文件路径:picodevil/main.py """Picodevil 主循环:LLM + 工具调用。""" import json import sys import yaml from llm_client import build_client, chat from tools import TOOL_SCHEMAS, TOOL_IMPL def load_config(path="config.yaml"): with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) def run_tool(name, args, project_root="."): """执行模型请求的工具调用。""" impl = TOOL_IMPL.get(name) if impl is None: return f"错误:未注册的工具 {name}" try: if name == "read_project_file": return impl(args, project_root) return impl(args) except Exception as e: return f"工具执行异常:{e}" def main(): cfg = load_config() client = build_client(cfg) messages = [ { "role": "system", "content": ( "你是 Picodevil,一个运行在用户本地的开发助手。" "你可以阅读项目文件、获取当前时间,并给出代码建议。" "回答要简洁、准确,代码示例需要标注语言。" "如果工具执行失败,请如实告知用户,不要编造工具结果。" ), } ] print("Picodevil 已启动,输入 exit 退出。") while True: try: user_input = input(">>> ") except (EOFError, KeyboardInterrupt): print("\n再见。") break if user_input.strip().lower() in ("exit", "quit"): break messages.append({"role": "user", "content": user_input}) # 工具调用循环:模型可能需要多轮调用工具后才给出最终回答 for _ in range(5): reply = chat(client, messages, tools=TOOL_SCHEMAS, cfg=cfg) if reply.tool_calls: messages.append(reply.model_dump()) for tool_call in reply.tool_calls: fn = tool_call.function try: args = json.loads(fn.arguments or "{}") except json.JSONDecodeError: args = {} result = run_tool(fn.name, args) messages.append( { "role": "tool", "tool_call_id": tool_call.id, "content": str(result), } ) else: print("AI:", reply.content) messages.append({"role": "assistant", "content": reply.content}) break if __name__ == "__main__": main()主循环的核心逻辑是内层 for 循环。每次循环先调用模型,如果返回 tool_calls 就执行工具并追加消息,然后继续循环;如果返回普通文本,就打印给用户并跳出循环。外层 for 设置了 5 次上限,防止模型陷入无限工具调用。
需要说明的是,这里的reply.model_dump()是 openai Python SDK 1.x 中消息对象转字典的方法。如果你使用的 SDK 版本较老,可以换成reply.dict()。这类细节在不同版本间有差异,请以你本地实际安装的版本为准。
4.6 运行与验证
启动 Picodevil:
cd picodevil python -m picodevil.main如果项目结构不是包形式,也可以直接运行:
cd picodevil/picodevil python main.py启动后你会看到交互提示符。下面两个测试用例可以帮助验证功能:
>>> 现在几点? AI: 2025-03-12 14:30:22 >>> 读取 config.yaml 并总结里面的配置 AI: config.yaml 中配置了 LLM 服务的 base_url、api_key、model、 temperature 和 max_tokens 等参数。其中 temperature 为 0.2, 表示模型输出会更倾向于稳定和确定。如果你得到的回答是这个形式,说明 LLM 请求、工具调用、工具结果回传这条链路已经全部打通。
4.7 结果说明
从运行结果可以看到,Picodevil 已经具备了一个最小 LLM Agent 的全部要素:有模型推理能力,有工具执行能力,有循环控制。当用户提出“读取 config.yaml”这类需求时,模型没有直接编造文件内容,而是先调用了 read_project_file 工具,拿到真实内容后再总结。这正是工具调用的价值所在。后续如果要增加搜索网页、执行 SQL、调用内部 API 等能力,只需要在 tools.py 中继续追加工具定义和实现即可。
5. 常见问题与排查思路
在实际开发 Picodevil 的过程中,我遇到了不少问题。下面整理成表格,方便快速定位。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 请求超时或连接失败 | base_url 配置错误,或网络不通 | 先单独测试模型接口连通性,再检查 base_url 路径是否为 /v1 |
| 返回内容提示超出上下文长度 | 消息列表过长,或文件内容过大 | 截断历史消息,对文件内容做长度限制,必要时做摘要 |
| 工具参数解析失败 | 模型返回的 arguments 不是合法 JSON | 捕获 JSONDecodeError,给模型回传错误信息并让它重试 |
| 模型无限调用工具 | 工具返回内容让模型误以为需要继续调用 | 设置最大循环轮数;检查工具返回结果是否清晰,避免歧义 |
| 工具读取到路径之外的文件 | 路径拼接未做安全校验 | 使用 realpath + startswith 双重校验,拒绝非法路径 |
| API Key 泄露到代码仓库 | 配置直接硬编码在源码中 | 使用环境变量或本地配置文件,并加入 .gitignore |
除了表格里的具体问题,整理一个通用的排查流程:先验证最底层能力,再逐步往上叠加。例如,Picodevil 出现异常时,我一般按以下顺序排查:
- 先用 curl 或 Python 脚本直接调用模型接口,确认模型服务和 API Key 正常。
- 再测试不带工具的普通对话,确认 LLM 客户端封装没有 bug。
- 然后测试单个工具调用,确认工具的 Schema 和实现都能正常工作。
- 最后测试多轮工具调用,确认循环和消息追加逻辑正确。
这个流程能帮助你快速定位问题究竟出在网络层、SDK 层、工具层还是循环控制层,避免在不明根因的情况下反复修改代码。
6. 最佳实践与工程建议
6.1 提示词版本化与评测
提示词是 LLM 应用的灵魂,但它也是最容易失控的部分。我建议把提示词模板从代码中抽离出来,放到独立的文件或配置项中,用 Git 管理每次改动。每次修改后,准备一组固定的测试用例,覆盖正常请求、边界输入、工具调用失败等场景,对比修改前后的输出质量。没有评测的提示词优化,往往只是感觉变好了,实际效果却难以追踪。
6.2 异常处理与限流
生产环境调用模型 API 时必须考虑异常。网络抖动、服务端限流、模型超时都是常见问题。处理思路包括:
- 对请求设置超时时间,例如 30 秒。
- 对 429(限流)、5xx(服务端错误)做指数退避重试。
- 把每次请求的耗时、Token 消耗、错误码写入日志。
在 Picodevil 中,这些逻辑可以统一放在 llm_client.py 的 chat 函数里,而不是散落在主循环中。
6.3 安全边界
LLM 应用的安全问题比传统应用更隐蔽,因为模型输出不可控。Picodevil 的实践也验证了这一点。以下几点非常重要:
- 不要把 API Key 硬编码在代码或配置中,优先使用环境变量。
- 工具层必须做路径校验,防止目录穿越。
- 谨慎设计可以执行命令的工具。如果要运行 shell 命令,尽量限制在白名单命令内,且只能在沙箱或测试环境执行。
- 对模型生成的代码,不要直接自动执行,必须先人工 review。
- 涉及数据库、生产环境变更时,工具应默认拒绝危险操作,并强制二次确认。
安全边界的原则是最小权限:模型能访问的资源越少,出错时的爆炸半径越小。
6.4 成本控制与可观测性
LLM API 是按 Token 计费的,上下文越长、调用轮数越多,成本越高。控制成本的常见手段包括:
- 精简 system 提示词,去掉不必要的长文本。
- 对历史消息做滑动窗口,只保留最近 N 轮。
- 工具返回内容不要全量回传,必要时先截断。
- 为单次会话设置最大调用轮数。
可观测性同样重要。建议打印每次请求的模型、Token 数、耗时和工具调用明细。这些数据能帮助你发现哪些请求异常昂贵,哪些工具调用频繁失败,从而优化整体设计。
6.5 从最小实现演进到 Agent 框架
Picodevil 的当前版本是几百行代码的最小实现,适合学习原理。但如果你要开发一个面向真实业务的项目,可以考虑引入成熟的 Agent 框架或编排框架。市面上有 LangChain、LlamaIndex 等通用框架,也有 Spring AI 这类面向 Java 生态的方案。它们提供了消息管理、工具注册、Agent 循环、向量检索等现成组件,可以减少重复造轮子的成本。
不过,框架不是必须的。如果你的场景只有两三个工具,自定义循环反而更可控、更轻量。建议先从最小实现跑通业务逻辑,再根据复杂度决定是否引入框架。这也是 Picodevil 项目一直坚持的原则:先小而快,后大而全。
7. 落地建议与下一步可以做什么
如果你也想从零搭建一个类似 Picodevil 的开发助手,我会建议你不要一开始就想做一个功能完整的产品,而是先跑通最小编译闭环。哪怕只有一个工具,只要模型能正确判断何时调用、工具能正确执行、结果能正确回传,整个链路的价值就已经体现出来了。之后再逐步增加工具、优化提示词、引入记忆和检索,每一步都基于真实使用反馈,而不是靠想象堆功能。
下一步值得尝试的方向有几个:第一,为 Picodevil 增加 RAG 能力,把项目文档、接口文档转为向量索引,让模型能回答“项目里 xxx 模块的调用方式是什么”这类检索型问题;第二,把命令行交互换成 HTTP 服务或 IDE 插件,让更多场景能接入;第三,增加对动态工具的注册机制,比如通过 JSON 配置文件声明新工具,避免每次都改代码。
最后留一个最实际的建议:在接入任何模型能力之前,先把工具的安全边界设计好。哪些路径可以读,哪些命令可以执行,哪些操作必须人工确认,这些规则越早定下来,后续迭代越省心。毕竟 LLM 的能力再强,也只是通过你提供的工具触达真实世界,工具层设计决定了它能做好事,也决定了它能闯多大的祸。
