agent学习Day5——Ruff、日志与结构化 LLM 输出

一、工程规范:让问题在提交前暴露

Ruff、pre-commit 和 logging 分别解决什么

今天先重新学习了项目工程规范。Ruff 是静态检查工具,主要检查未使用的 import、import 排序、代码格式等问题;pre-commit 则把 Ruff 挂到git commit上,提交前自动检查,不通过就拒绝提交。

git commit -> pre-commit hook -> ruff check -> 通过才能创建 commit

实际测试时,我故意在main.py中加入未使用的import os。提交时 pre-commit 报出:

F401 `os` imported but unused I001 Import block is un-sorted or un-formatted

说明 hook 已经生效。修复后,git diff --check又发现了 Ruff 当前规则未覆盖的行尾空格问题。

经验:Ruff、pre-commit 和git diff --check不是重复工具。它们关注的点不同,提交前一起跑更稳妥。

logging:不只是打印信息

print()只能临时调试;真正的项目应该使用 logging,因为日志有级别、时间、模块来源,也可以在以后接入文件或监控系统。

logging.basicConfig(level=logging.INFO,format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",)logger=logging.getLogger(__name__)

这里:

  • basicConfig()只在main.py这类应用入口配置一次;
  • 每个模块用getLogger(__name__)获取自己的 logger;
  • INFO记录正常业务事件;
  • logger.exception()记录失败事件和完整异常堆栈。

调用/health/api/v1/jd/analyze-basic后,可以同时看到业务日志和 Uvicorn 访问日志:

app.api.routes.jd - INFO - analyze-basic called, jd_text length=33 app.api.routes.jd - INFO - analyze-basic response: word_count=33, has_python=True INFO: 127.0.0.1 - "POST /api/v1/jd/analyze-basic HTTP/1.1" 200 OK

业务日志说明“代码内部做了什么”,访问日志说明“请求最终返回了什么状态”。

二、封装 JD 分析链路:从脚本到服务层

为什么要封装

之前 Prompt、LLM 调用和 JSON 解析分散在scripts/llm_playground.py中。脚本能跑不代表项目可复用:未来路由、Agent 或其他功能都不应该知道 Prompt 怎么拼、模型怎么调、JSON 怎么清洗。

所以将职责拆成两层:

  • app/services/llm_service.py:负责调用模型并返回原始字符串;
  • app/services/jd_analysis.py:负责 JD Prompt、JSON 清洗、Pydantic 校验和结果返回。

主链路最终变成:

JD 文本 -> build_jd_user_prompt() -> call_llm() -> 原始模型文本 -> clean_json_response() -> json.loads() -> JdAnalysisResult.model_validate() -> 返回结构化结果

核心函数如下:

defanalyze_jd(jd_text:str)->JdAnalysisResult:user_prompt=build_jd_user_prompt(jd_text)raw_response=call_llm(prompt=user_prompt,system_prompt=SYSTEM_PROMPT_JD,temperature=0,)returnparse_jd_analysis(raw_response)

temperature=0的目的是让结构化抽取尽量稳定,避免模型自由发挥。

LLM 调用层的日志

call_llm()中记录 Prompt 长度、耗时、输出长度和异常状态:

start_time=time.perf_counter()try:response=client.chat.completions.create(...)exceptException:logger.exception("LLM call failed: prompt_length=%s, duration_ms=%.2f",len(prompt),duration_ms,)raise

这里使用time.perf_counter()而不是系统时间,因为它更适合测量耗时:精度高,并且不会因为系统校时发生跳变。

日志只记录长度、耗时和状态,不记录完整 JD 或 API Key,避免敏感信息泄露。

三、固定假响应测试:先验证链路,再调用真实模型

为什么不能只测真实 API

真实模型输出有波动,也会消耗额度。自动化测试如果依赖真实 API,可能今天通过、明天失败,无法稳定定位代码问题。

因此先用 fake LLM 替换真实call_llm()

deffake_call_llm(prompt:str,system_prompt:str,temperature:float,)->str:return"""```json { "job_title": "Python后端工程师", "required_skills": ["Python", "FastAPI"], "responsibilities": ["开发和维护 REST API"], "keywords": ["Python", "FastAPI"], "difficulty": "中等" } ```"""

测试覆盖了五种情况:

  • 正常 JSON;
  • 带 Markdown 围栏的 JSON;
  • LLM 调用异常;
  • 非法 JSON;
  • JSON 合法但字段类型错误。

两类错误的区别

JSONDecodeError -> 模型输出不是合法 JSON,无法转成 Python dict ValidationError -> JSON 可以解析,但字段缺失或类型不符合 JdAnalysisResult

还有第三类:内容理解错误。例如 JD 明确要求 Redis,但模型返回的技能列表漏掉 Redis。此时 JSON 合法、类型也正确,Pydantic 无法发现,因为它只校验数据结构,不理解原始 JD 内容。

四、真实 JD 质量检查

最后用 5 段固定 JD 测试了 Python 后端、机器学习、前端、数据分析和 AI 产品经理岗位。

结构化解析成功率:5/5

每次真实调用都记录了 Prompt 长度、耗时和输出长度,平均耗时约 1 到 2 秒。

质量检查中发现一个值得继续优化的点:FastAPI 或 DjangoReact 或 Vue这类候选技能会被扁平化为普通列表。模型没有编造技能,但当前required_skills: list[str]无法表达“二选一”。

后续可以增加类似字段:

alternative_skills skill_groups

让结构化结果更准确地保留 JD 的原始语义。

本次完成的内容包括 LLM 调用封装、JD 分析主链路、日志、固定假响应测试和真实 JD 质量检查。