最近在落地 AI Agent 招聘助手的时候,遇到一个很现实的问题:职位数据散落在不同平台、不同接口、不同字段定义里,Agent 想要统一消费这些数据非常困难。如果有一套面向 Agent 消费的开放职位数据协议,整个链路就会清晰很多。这也正是 OJCP(Open Job Consumption Protocol)这个方向要解决的问题。
本文将围绕 OJCP 的设计理念、核心数据模型、Agent 消费流程、服务端接入示例以及常见踩坑点展开,希望给正在做 Agent 开发、职位聚合平台或招聘数据服务的朋友一些可落地的参考。
1. 背景与核心概念
1.1 为什么需要面向 Agent 的职位数据协议
先看一个常见场景:你正在开发一个 AI 招聘助手,用户说“帮我找最近一周发布的、深圳的、Java 后端岗位,要求月薪 25K 以上”。
传统做法是去调用某个招聘平台的开放 API,然后把返回的 JSON 字段映射到自己的数据结构。问题也随之而来:
- 每个平台的字段命名不同,有的叫
salary、有的叫payRange、有的叫compensation。 - 数据嵌套层级不同,有的返回到
data.list,有的返回到content.positions。 - 匹配规则不透明,平台可能默认做了相关性排序,Agent 无法判断数据是否完整。
- 状态字段混乱,
status可能是数字、字符串、枚举,含义完全不一样。
当你的 Agent 只需要对接一个平台时,这些问题还能通过硬编码解决;但一旦要对接多个来源,或者想要做一个通用的职位数据消费层,协议不一致的维护成本会快速膨胀。
OJCP 的思路是:在职位数据提供方和 Agent 消费方之间,定义一个统一的、可扩展的开放协议。数据提供方按协议输出标准化结构,Agent 按协议解析数据,两边都不需要关心对方内部实现。
1.2 OJCP 是什么
从命名来看,OJCP 是 “Open Job Consumption Protocol” 的缩写,翻译过来是“开放职位消费协议”。它不是一个具体的软件,也不是某个公司的 SDK,而是一套描述职位数据如何暴露、如何获取、如何解析的规范。
它关注的核心问题有三个:
- 数据格式:职位数据应该包含哪些字段,字段类型和含义是什么。
- 交互方式:Agent 如何发现职位数据源,如何发起查询,如何获取详情。
- 状态语义:职位从发布、下架、暂停到关闭,状态如何表达。
你可以把它理解成职位数据领域的“通用语言”。只要提供方和消费方都遵循这套语言,Agent 就能像阅读标准文档一样读取职位数据。
1.3 OJCP 与 MCP、普通招聘 API 的区别
近两年 Agent 领域经常提到 MCP(Model Context Protocol)、Agent Skills、Agent CLI 等概念,很多人会把它们混在一起,这里简单区分一下。
| 概念 | 定位 | 与 OJCP 的关系 |
|---|---|---|
| MCP | 大模型与外部工具之间的标准化调用协议 | OJCP 可以作为 MCP Server 暴露的某一种数据协议,两者是不同层面的东西 |
| Agent Skills | Agent 可复用技能的定义 | OJCP 可以理解为职位消费场景下的“领域技能” |
| 普通招聘 API | 面向人类开发者设计的接口 | OJCP 更强调数据结构的可解释性、状态语义明确性,适合 Agent 直接消费 |
简单来说,普通招聘 API 是给人看的,返回数据需要人去读文档、写映射;OJCP 是给 Agent 消费的,数据结构和语义尽量做到自解释,减少 Agent 的猜测成本。
2. 协议设计目标与适用范围
2.1 设计目标
一套协议如果设计得太复杂,Agent 解析成本高,平台接入意愿低;设计得太简单,又覆盖不了真实业务。OJCP 的设计目标可以拆成以下几条:
数据自描述 Agent 拿到一条职位记录后,不需要外部文档,仅凭字段名和结构就能理解这条数据的含义。
格式中立 协议定义的是数据语义和结构,不绑定特定传输方式。HTTPS 可以,gRPC 可以,甚至离线 JSON 文件也可以。
渐进式扩展 基础字段是所有接入方必须支持的,扩展字段允许各自补充。比如基础字段有
title、location、salary,扩展字段可以有equity、visaSponsorship。状态机清晰 职位在不同平台上有不同的生命周期,协议要定义一个通用状态机,避免出现“A 平台下架=B 平台关闭”这种语义混乱。
查询语义统一 Agent 发起查询时,筛选条件、排序方式、分页方式要有一致约定。
2.2 适用场景
从实际经验来看,OJCP 适合这几类场景:
- 招聘聚合平台:把多个渠道的职位数据统一转为 OJCP 格式,输出给 Agent 或下游系统。
- AI 招聘助手:Agent 通过标准接口获取职位数据,做筛选、匹配、推荐。
- 企业内部职位流转:HR 系统与内部招聘工具之间的数据同步。
- 数据服务商:向外部提供职位数据订阅服务时,用 OJCP 作为输出标准。
如果你的项目只是内部使用的简单职位表,不需要对接外部 Agent,那引入这套协议会有一定成本,可以根据实际情况权衡。
3. OJCP 核心数据模型设计
3.1 基础字段设计
职位数据的核心字段不需要太多,但每个字段都要语义清晰。下面是一份建议的基础字段设计,实际使用时可在此基础上扩展。
| 字段名 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | string | 是 | 职位唯一标识,建议由提供方生成 |
| title | string | 是 | 职位名称 |
| description | text | 是 | 职位描述,支持纯文本或结构化文本 |
| location | object | 是 | 工作地点,包含城市、区域、是否远程 |
| salary | object | 否 | 薪资范围,包含币种、最小值、最大值、周期 |
| employmentType | string | 是 | 工作类型,如 full_time、part_time、contract |
| status | string | 是 | 职位状态,遵循协议状态机 |
| publishedAt | string | 是 | 发布时间,ISO 8601 格式 |
| updatedAt | string | 是 | 更新时间,ISO 8601 格式 |
| company | object | 否 | 公司信息,包含名称、Logo、规模等 |
| skills | array | 否 | 技能标签数组 |
| source | string | 是 | 数据来源标识,用于追踪数据归属 |
这里强调几个容易踩坑的点:
salary不要设计成字符串,比如 “25K-35K”。Agent 解析这种字符串需要额外的模式匹配,一旦格式不统一就会出错。建议拆成min、max、currency、period。employmentType使用枚举字符串,不要用数字。数字无法自解释,Agent 必须查表才能理解。- 时间字段统一使用 ISO 8601,不要用时间戳。时间戳虽然简洁,但缺少时区语义,Agent 需要额外换算。
3.2 状态机定义
职位状态看起来简单,实际在多方协作时会很混乱。一个职位可能经历以下过程:
- 发布方创建职位。
- 审核通过后上线。
- 招聘满员后暂停。
- 最终关闭或删除。
如果协议不统一状态定义,A 平台把暂停叫paused,B 平台叫on_hold,C 平台叫suspended,Agent 处理起来会非常痛苦。
建议定义如下状态集合:
| 状态值 | 含义 | 后续可能状态 |
|---|---|---|
| draft | 草稿,尚未公开可见 | published、closed |
| published | 已发布,Agent 可正常获取 | paused、closed |
| paused | 暂停招聘,数据仍存在但不应推荐 | published、closed |
| closed | 已关闭,不再招聘 | draft |
| removed | 已删除,应从本地缓存中移除 | 无 |
需要注意的是,removed状态代表数据不可恢复,提供方在返回该状态时应确保 Agent 删除本地缓存。
3.3 查询与分页约定
Agent 需要按条件筛选职位,协议中建议约定一套统一的查询参数风格。
常见查询参数:
q:全文搜索关键词。location:地点筛选,如shenzhen或beijing。employment_type:工作类型筛选。min_salary、max_salary:薪资范围筛选。status:状态筛选,默认只返回published。published_after:按发布时间筛选。page、page_size:分页参数。
分页响应建议包含以下字段:
{ "data": [], "pagination": { "page": 1, "page_size": 20, "total": 156, "has_more": true } }has_more字段非常重要,Agent 可以通过它判断是否继续翻页,避免额外请求。
4. Agent 消费 OJCP 数据实战
4.1 场景设定
假设我们现在要开发一个 AI 招聘助手,从一个遵循 OJCP 协议的职位数据服务中拉取深圳地区的 Java 后端岗位,然后交给大模型做筛选和推荐。
整体流程:
- Agent 构造查询请求。
- 职位服务返回 OJCP 格式的数据。
- Agent 解析数据并转换为内部结构。
- Agent 调用大模型对职位进行筛选匹配。
- Agent 返回推荐结果给用户。
4.2 提供方接口示例(FastAPI)
我们先实现一个简单的 OJCP 职位数据服务接口。这里以 Python FastAPI 为例,演示如何把职位数据以 OJCP 格式暴露出去。
# 文件路径:main.py from datetime import datetime, timezone from typing import List, Optional from fastapi import FastAPI, Query from pydantic import BaseModel app = FastAPI(title="OJCP Job Data Service") class Salary(BaseModel): currency: str min: Optional[int] = None max: Optional[int] = None period: str = "year" class Location(BaseModel): city: str region: Optional[str] = None remote: bool = False class JobPosting(BaseModel): id: str title: str description: str location: Location salary: Optional[Salary] = None employment_type: str status: str published_at: str updated_at: str company: Optional[dict] = None skills: List[str] = [] source: str # 模拟数据库中的职位数据 JOBS_DB = [ JobPosting( id="job-001", title="高级Java后端工程师", description="负责核心交易系统的设计与开发...", location=Location(city="深圳", region="南山区", remote=False), salary=Salary(currency="CNY", min=25000, max=40000, period="month"), employment_type="full_time", status="published", published_at="2025-01-10T10:00:00Z", updated_at="2025-01-10T10:00:00Z", company={"name": "示例科技", "size": "200-500人"}, skills=["Java", "Spring Boot", "MySQL", "Redis"], source="demo-source", ), JobPosting( id="job-002", title="Java开发工程师(远程)", description="参与金融风控系统研发,支持远程办公...", location=Location(city="上海", region=None, remote=True), salary=Salary(currency="CNY", min=20000, max=30000, period="month"), employment_type="full_time", status="published", published_at="2025-01-08T10:00:00Z", updated_at="2025-01-09T10:00:00Z", company={"name": "金融科技公司", "size": "50-150人"}, skills=["Java", "分布式系统", "Kafka"], source="demo-source", ), ] @app.get("/ojcp/jobs", response_model=dict) def list_jobs( q: Optional[str] = None, location: Optional[str] = None, employment_type: Optional[str] = None, min_salary: Optional[int] = None, status: str = Query("published", description="职位状态"), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), ): """ 以 OJCP 协议返回职位列表。 这里演示了过滤、分页的参考实现。 """ filtered = [job for job in JOBS_DB if job.status == status] if q: filtered = [job for job in filtered if q.lower() in job.title.lower()] if location: filtered = [job for job in filtered if job.location.city == location] if employment_type: filtered = [job for job in filtered if job.employment_type == employment_type] if min_salary is not None: filtered = [ job for job in filtered if job.salary and job.salary.min and job.salary.min >= min_salary ] total = len(filtered) start = (page - 1) * page_size end = start + page_size page_data = filtered[start:end] return { "data": [job.model_dump() for job in page_data], "pagination": { "page": page, "page_size": page_size, "total": total, "has_more": end < total, }, "protocol": "ojcp", "version": "1.0", }这段代码实现了最基础的 OJCP 列表接口。注意几个细节:
- 响应中带有
protocol和version字段,方便 Agent 识别协议版本。 - 分页参数限制了
page_size最大为 100,避免单次返回数据量过大。 model_dump()是 Pydantic v2 的写法,如果使用 Pydantic v1,需要改成dict()。
启动服务:
uvicorn main:app --reload --port 8000访问http://127.0.0.1:8000/ojcp/jobs?location=深圳可以验证返回结果。
4.3 Agent 消费端示例(Python)
接下来写一个 Agent 端的消费程序,从 OJCP 服务拉取数据并交给大模型处理。
# 文件路径:agent_consumer.py import requests class OJCPClient: """ 一个极简的 OJCP 客户端,负责从服务端拉取职位数据。 更完整的实现可以加入重试、缓存、异步请求等能力。 """ def __init__(self, base_url: str, timeout: int = 10): self.base_url = base_url.rstrip("/") self.timeout = timeout def fetch_jobs(self, params: dict) -> list: """ 按照 OJCP 协议拉取职位列表,自动处理分页。 """ all_jobs = [] page = 1 page_size = params.get("page_size", 20) while True: query_params = {**params, "page": page, "page_size": page_size} resp = requests.get( f"{self.base_url}/ojcp/jobs", params=query_params, timeout=self.timeout, ) resp.raise_for_status() payload = resp.json() # 协议版本校验 if payload.get("protocol") != "ojcp": raise ValueError("响应不是 OJCP 协议格式") data = payload.get("data", []) all_jobs.extend(data) pagination = payload.get("pagination", {}) if not pagination.get("has_more", False): break page += 1 return all_jobs def filter_jobs_for_user(jobs: list, user_keyword: str, user_min_salary: int) -> list: """ 从 OJCP 数据中筛选岗位。 这里只做规则过滤,实际项目中可以接入大模型做语义匹配。 """ matched = [] for job in jobs: title = job.get("title", "") salary_obj = job.get("salary") or {} if user_keyword.lower() not in title.lower(): continue salary_min = salary_obj.get("min") or 0 if salary_min < user_min_salary: continue matched.append( { "id": job.get("id"), "title": title, "city": job.get("location", {}).get("city"), "company": (job.get("company") or {}).get("name"), "salary": salary_obj, "description": job.get("description"), "published_at": job.get("published_at"), } ) return matched if __name__ == "__main__": client = OJCPClient(base_url="http://127.0.0.1:8000") jobs = client.fetch_jobs( params={ "location": "深圳", "employment_type": "full_time", "status": "published", "page_size": 50, } ) print(f"共拉取职位: {len(jobs)} 条") result = filter_jobs_for_user(jobs, user_keyword="Java", user_min_salary=25000) print(f"符合用户条件的职位: {len(result)} 条") for item in result: print(f"- {item['title']} | {item['city']} | {item['company']}")在这个示例中,OJCPClient自动处理了分页逻辑,Agent 只需要传入查询参数即可拿到全部符合条件的职位。
4.4 接入大模型做语义推荐
规则筛选能解决“Java + 25K + 深圳”这样的明确条件,但用户往往会有模糊需求,比如“想找一个不那么卷的公司”或“希望团队技术氛围好”。这时候需要借助大模型对职位描述进行语义分析。
下面是一个接入大模型的示例思路,核心是把 OJCP 数据转换为适合 LLM 的文本片段:
# 文件路径:llm_recommend.py # 注意:这里演示的是流程思路,实际调用时请根据你使用的模型服务调整。 import json # 假设这是从 OJCP 接口拿到的职位数据 job_data = { "id": "job-001", "title": "高级Java后端工程师", "description": "负责核心交易系统的设计与开发,团队技术氛围好,没有强制加班文化。", "location": {"city": "深圳", "region": "南山区", "remote": False}, "salary": {"currency": "CNY", "min": 25000, "max": 40000, "period": "month"}, "company": {"name": "示例科技", "size": "200-500人"}, "skills": ["Java", "Spring Boot", "MySQL", "Redis"], } def build_prompt(user_requirement: str, job: dict) -> str: """ 将 OJCP 职位数据转换为 LLM 可处理的 prompt。 """ job_summary = json.dumps(job, ensure_ascii=False, indent=2) return f""" 用户需求:{user_requirement} 职位数据(OJCP 格式): {job_summary} 请根据用户需求评估该职位的匹配度,并给出推荐理由。 如果匹配,请说明哪些信息让该职位适合用户;如果不匹配,请给出原因。 """ prompt = build_prompt("我在找一份能兼顾生活和工作的后端岗位", job_data) print(prompt) # 实际调用大模型时: # response = your_llm_client.chat.completions.create( # model="your-model", # messages=[{"role": "user", "content": prompt}], # ) # print(response.choices[0].message.content)这里要强调的是:不要把全部原始描述直接丢给大模型,可以先做字段裁剪和清洗,减少 token 消耗。对于长文本,可以只保留description的前 N 个字符。
4.5 运行与验证
按照上面的步骤,先启动 FastAPI 服务,再运行消费端脚本。
预期输出类似:
共拉取职位: 1 条 符合用户条件的职位: 1 条 - 高级Java后端工程师 | 深圳 | 示例科技如果返回 0 条,可以排查以下几个方面:
- fastapi 服务是否正常启动。
- 查询条件是否过严。
- 职位状态是否为
published。 - 薪资字段是否满足
min_salary条件。
5. 常见问题与排查思路
在实际开发中,Agent 消费职位数据时会遇到各种问题。下面整理成表格,方便遇到类似报错时快速定位。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| Agent 请求超时 | 服务端处理慢或网络不通 | 检查服务状态,确认接口 URL 可访问 |
| 返回数据为空 | 查询条件过严或数据源未发布职位 | 先不带筛选条件请求一次,确认是否有数据 |
| 字段解析报错 | 提供方未严格遵循协议,缺失必填字段 | 增加字段校验,对缺失字段设置默认值 |
| 薪资字段无法比较 | 薪资格式不统一,存在字符串表示 | 协议规定使用结构化对象,做好数据转换 |
| 分页循环不终止 | has_more一直为 true | 检查服务端分页逻辑,设置最大页数限制 |
| 大模型内容被截断 | 职位描述过长 | 对 description 做截断或分段处理 |
| 出现重复职位 | 多次翻页或接口幂等性不足 | 消费端对id做去重 |
另外,很多朋友在 Agent 开发中会看到类似the agent execution provider did not respond in time的报错。这通常不是 OJCP 本身的问题,而是 Agent 运行时调用某个工具或模型服务时超时。处理思路是:
- 调大执行超时时间。
- 检查依赖的外部服务是否可达。
- 为 Agent 调用增加重试机制。
- 把大任务拆分为多个小任务,避免单次执行时间过长。
OJCP 主要解决的是数据协议的标准化问题,但 Agent 工程的健壮性还需要在超时、重试、缓存、幂等这些基础设施能力上下工夫。
6. 最佳实践与工程建议
6.1 数据提供方的最佳实践
如果你负责提供 OJCP 格式的职位数据,以下建议值得参考:
字段语义要稳定 协议中最怕“同一个字段,不同时期含义不同”。比如
status=published早期表示“已发布”,后来改成“审核中”,下游 Agent 的行为就会错乱。字段语义一旦定下来,尽量保持稳定。增加协议版本号 响应体中加入
protocol_version或version字段。未来有破坏性变更时,通过版本号平滑过渡。控制单次返回的数据量 支持
page_size限制,建议最大不超过 100。单条职位数据要避免塞入过长的字段,比如超长的 HTML 描述。提供数据变更感知能力 Agent 往往需要增量同步,提供方可以增加
updated_since参数,或者提供 Webhook 订阅机制,减少 Agent 全量拉取的压力。明确数据归属和更新频率 在响应中增加
source和updated_at,方便 Agent 判断数据新鲜度。
6.2 Agent 消费方的最佳实践
本地缓存 + 增量更新 Agent 每次请求都全量拉取会非常低效。可以本地缓存职位数据,配合
updated_since做增量更新。字段容错 OJCP 协议要求必填字段,但真实环境下总有服务方不按协议输出。在消费端做一层字段容错,比如缺失
salary时使用默认值,避免解析异常。对职位 ID 做去重 多页拉取、多次同步都可能产生重复数据,消费端要维护一个已处理 ID 集合。
敏感信息过滤 职位数据中可能包含联系方式、内部备注等信息。Agent 在输出内容前要过滤敏感字段,避免泄露。
大模型调用成本控制 不是所有职位都需要调用大模型。先用规则筛选缩小范围,再对候选职位做语义分析,能显著降低成本。
6.3 安全与合规建议
涉及生产环境和外部数据时,要特别注意以下边界:
- OJCP 接口的访问权限要控制,至少使用 API Key 或 Token 认证,不能裸奔在公网。
- 职位数据可能包含个人信息,输出给 Agent 前要完成脱敏。
- Agent 消费数据后不应无限期缓存,建议设置合理的 TTL(生存时间)。
- 涉及跨平台职位数据聚合时,要注意数据来源的授权问题,不要未经授权抓取数据。
- 删除接口、批量更新操作要在测试环境充分验证,生产环境遵循最小权限原则。
6.4 协议演进与扩展
基础 OJCP 协议可以覆盖大部分通用场景,但真实业务中还会有垂直需求,比如:
- 候选人投递链路:职位数据除了展示,还需要投递简历。
- 薪酬福利的更多维度:期权、股票、签字费、年终奖。
- 职位与技能的关联:技能标签的层级与权重。
- 多语言职位:同一职位在不同地区有不同语言的描述。
面对这类需求,建议采用扩展字段的方式:
{ "id": "job-001", "title": "Senior Java Engineer", "description": "...", "extensions": { "visaSponsorship": true, "equity": {"min": 0.01, "max": 0.05, "unit": "percent"}, "interviewProcess": ["HR Screen", "Tech Interview", "Onsite"] } }extensions字段为自定义扩展保留空间,Agent 如果认识这些字段可以解析,不认识可以直接忽略,不影响基础功能。
7. 从 OJCP 到完整 Agent 服务:进阶方向
到这里,我们已经完成了 OJCP 协议从概念到实战的闭环。但要把一个 Agent 招聘助手真正落地到生产,还有几个方向值得继续深入。
Agent 与 MCP 的集成 如果你已经在使用 MCP,可以把 OJCP 数据源封装成 MCP Server 的 Tool。Agent 通过 MCP 协议调用 Tool,Tool 内部再访问 OJCP 接口,这样 Agent 就不需要直接面对 HTTP 细节。
多数据源聚合 一个 Agent 往往要消费多个平台的职位数据。你可以为每个平台写一个适配器,统一转换为 OJCP 结构,再由上层的 Agent 统一消费。这样即使新增数据源,也只需要新增适配器。
职位数据的语义增强 从 OJCP 基础数据中,可以进一步抽取技能图谱、公司标签、薪资分布等衍生数据,构建更丰富的岗位画像,让 Agent 的匹配能力更强。
Agent 记忆与个性化推荐 通过记录用户的搜索历史、点击行为和投递反馈,让 Agent 逐步理解用户的职业偏好。这里的偏好数据可以独立于职位数据存储,但在推荐阶段与 OJCP 职位数据做交汇。
Agent 编排与任务拆分 一个完整的招聘流程可能包含:职位搜索、简历生成、岗位投递、面试安排。每个环节都可以设计成一个独立的 Agent 子任务,主 Agent 负责任务编排。这里就涉及多 Agent 协作模式,比如主从模式、任务规划与执行分离等。
OJCP 解决了“Agent 读不懂职位数据”这个基础问题,但真正让 Agent 有价值的是它背后的业务闭环。数据标准是地基,任务拆解、模型调用、反馈优化才是上层建筑。建议你在动手实现 OJCP 接入的同时,把 1 到 2 个真实的业务闭环跑通,比如“搜索职位 → 分析匹配度 → 生成投递建议”,这样才能真正体会到协议标准带来的效率提升。
如果这篇文章对你有帮助,建议先在自己负责的模块里把职位数据按 OJCP 结构整理一遍,再写一个最小的 Agent 消费脚本。不用一开始就追求完美,先让数据流动起来,后面再逐步完善协议细节。