人工智能,撰写和设计我的简历Agentic Workflow教程 (适合创业项目)阅读需22分钟左右

       

        欢迎来到雲闪世界。简历并不是向就业市场展示自己的唯一方式。然而,尽管信息和图形技术发生了许多创新,但简历仍然存在。

从高层次的角度来说,简历的创建可以:

  1. 以文件形式总结一个人过去的成就和经历,
  2. 以与特定受众相关的方式,在短时间内评估该人对某种目的的相对和绝对效用,
  3. 选择文件形式的样式和布局以利于上述受众的良好评价。

这些是在模糊约束下为实现目标而进行的语义操作。

大型语言模型 (LLM) 是使用计算机执行语义操作的主要手段,尤其是当这些操作像人类交流中经常出现的那样模棱两可时。迄今为止,与 LLM 交互的最常见方式是聊天应用程序 — ChatGPTClaudeLe Chat等。我们,即上述聊天应用程序的人类用户,通过聊天消息来稍微松散地定义语义操作。

然而,某些应用程序更适合使用不同的界面和创建语义操作的不同方式。聊天并不是 LLM 的全部。

         我将使用Anthropic 的 LLM 模型(尤其是 Sonnet 和 Haiku)的 API 来创建一个基本的 CV 组装应用程序。它依赖于协同工作的代理工作流程(代理工作流程),每个代理在从个人数据和历史记录块到值得其祖先使用的结构化 CV 文档的一系列操作中执行一些语义操作……

这是一篇关于构建小型但完整的 LLM 驱动的非聊天应用程序的教程。接下来,我将描述代码、我进行特定设计的原因以及每段 Python 代码在大局中的位置。

简历创建应用程序是人工智能处理结构化风格内容生成这一一般任务的一个有用例证。

所有代码和合成数据都可以在我的 GitHub repo中找到需联系博主给予资料。

代码之前 & 如何 - 显示内容 & Wow

想象一下,一组个人数据和冗长的职业描述,大部分是文本,组织成几个文件,信息分散。这些文件就是简历的原始材料。只是需要花些功夫将相关内容与不相关内容区分开来,提炼和完善,并赋予其良好而令人愉悦的形式。

接下来想象运行一个脚本make_cv并将其指向一个招聘广告、一个简历模板、一个人和一些规范参数:

make_cv --job-ad-company "epic resolution index" \--job-ad-company "epic resolution index" \
        --job-ad-title "luxury retail lighting specialist" \
        --cv-template two_columns_abt_0 \
        --person-name "gregor samsa" \
        --output-file ../generated_output/cv_nice_two_columns.html \
        --n-words-employment 50 --n-skills 8 --n-words-about-me 40

然后等待几秒钟,数据被重新排列、转换和呈现,之后脚本会输出一个样式整齐、内容丰富的单页双列简历。

一份风格整洁、内容抽象的简历,通过(LLM) 的代理工作流程生成。

  太棒了!布局简洁,风格以绿色为主,文字和背景对比鲜明,没有单调的默认字体,内容简洁明了。

但是等等……这些文件不是应该让我们脱颖而出吗?

再次借助LLM课程,创建了一个不同的模板(关键词:20 世纪 90 年代早期狂野而古怪的网页设计世界),并且相同的内容被赋予了新的辉煌形式:

一份具有疯狂的 1990 年代网页风格和内容的简历

       如果您忽略那些炫目的动画和奇特的颜色选择,您会发现内容和布局几乎与之前的简历完全相同。这并非偶然。代理工作流程的生成任务分别处理内容、形式和样式,而不是采用一体化解决方案。工作流程过程更像标准简历的模块化结构。

也就是说,代理工作流的生成过程是在有意义的约束条件下运行的。这可以增强生成式人工智能应用程序的实用性——毕竟,设计在很大程度上取决于约束。例如,品牌、风格指南和信息层次结构都是有用的、原则性的约束,我们应该希望在生成式人工智能的非聊天输出中使用这些约束——无论是简历、报告、用户体验、产品包装等。

完成所有这些的代理工作流程如下所示。

应用程序的高级数据流图

如果您想跳过代码和软件设计的描述,格里高尔·萨姆萨就是您的指南针。当我回来讨论应用程序和输出时,我会讨论虚构人物格里高尔·萨姆萨的合成数据,因此请通过关键字搜索来继续。

完整的代码可以在联系这个 GitHub repo中找到,免费且不提供任何担保。

招聘广告预处理、DAO 和即时组装

人们常说,简历内容应该根据招聘广告量身定制。由于招聘广告通常很冗长,有时还包含法律样板和联系信息,因此我希望仅提取和总结相关特征,并在后续任务中使用该文本。

为了在检索数据时拥有共享接口,我创建了一个基本的数据访问对象(DAO),它定义了数据的通用接口,在本教程示例中,该数据以文本和 JSON 文件的形式本地存储在其中(存储在中registry_job_ads),但通常可以是任何其他招聘广告数据库或 API。

from abc import ABC, abstractmethod
from ._local_data_repo import (
    registry_job_ads,
)


class DAO(ABC):
    def __init__(self):
        pass

    @abstractmethod
    def get(self, *args, **kwargs) -> str:
        pass

    @abstractmethod
    def keys(self) -> list:
        pass
      

class JobAdsDAO(DAO):
    def __init__(self):
        super().__init__()
        self.registry = registry_job_ads

    def get(self, company: str, position: str) -> str:
        return self.registry.get(company, position)

    def keys(self) -> list:
        return list(self.registry.registry.keys())

总结或抽象文本是 LLM 非常适合的语义操作。为此,

  1. 需要一个指令提示,以使 LLM 适当地处理文本以完成任务;
  2. 并且必须选择 Anthropic 的 LLM 模型及其参数(例如温度);
  3. 并且通过第三方API调用所指导的LLM,其对语法、错误检查等有特定的要求。

为了区分这三个不同的问题,我引入了一些抽象。

下面的类图说明了提取招聘广告关键特征的代理的关键方法和关系。

在代码中,它看起来像这样:

from typing import Dict
import json

from anthropic import Anthropic

from .confs import agent_model_extractor_conf
from .prompt_maker import get_prompt_for_
from .agent_base import AgentBareMetal


def get_core_model_conf(agent_kind: str) -> Dict[str, str]:
    with open(agent_model_extractor_conf, 'r') as f:
        model_conf = json.load(f)
    try:
        standard_model_conf = model_conf['extractors'][agent_kind]
    except KeyError:
        raise ValueError(f'Agent kind "{agent_kind}" not found in model configuration file')

    return {key: model_conf['params'][key][value] for key, value in standard_model_conf.items()}


class JobAdQualityExtractor:
    """Agent that extracts key qualities and attributes from a job ad
    Args:
        client: The Anthropic client.
    """
    def __init__(self,
                 client: Anthropic,
                 ):
        self.agent = AgentBareMetal(
            client=client,
            instruction=get_prompt_for_(self.__class__.__name__),
            **get_core_model_conf(self.__class__.__name__)
        )

    def extract_qualities(self, text: str) -> str:
        return self.agent.run(text)

配置文件agent_model_extractor_confs是一个 JSON 文件,其部分内容如下所示:

{
  "extractors": {
    "JobAdQualityExtractor": {
      "model": "haiku",
      "temperature": "low",
      "max_tokens": "high"
    },
  },
  "params": {
    "model": {
      "haiku": "claude-3-haiku-20240307",
      "sonnet": "claude-3-5-sonnet-20240620"
    },
    "temperature": {
      "high": 1.0,
      "medium": 0.4,
      "low": 0.1,
      "very_low": 0.001
    },
    "max_tokens": {
      "high": 4096
    }
  }
}

随着更多代理的实施,其他配置也会添加到此文件中。

提示是将一般的 LLM 重点放在特定能力上的东西。我使用 Jinja 模板来组装提示。这是一种灵活且成熟的方法,可以创建具有程序内容的文本文件。对于相当简单的招聘广告提取器代理,逻辑很简单 — 从文件中读取文本并返回 — 但是当我使用更高级的代理时,Jinja 模板将更有帮助。

import os
from jinja2 import Environment, FileSystemLoader

_prompt_templates_dir_name = 'prompt_templates'
env = Environment(
    loader=FileSystemLoader(
        searchpath=os.path.join(os.path.dirname(os.path.abspath(__file__)), _prompt_templates_dir_name)
    )
)


def get_prompt_for_(agent_type: str, **kwargs):
    """Construct the prompt from the template and data
    Note that the data to be inserted into the template is passed as keyword arguments
    where the key has to match the variable name in the template
    This also assumes the prompt templates is a text file named after the agent and with
    the extension '.txt'
    Args:
        agent_type (str): Type of the agent
        **kwargs: Data to be inserted into the template
    """
    return env.get_template(f'{agent_type}.txt').render(**kwargs)

的提示模板agent_type='JobAdQualityExtractor是:

您的任务是分析招聘广告,
从中一方面提取
公司希望应聘者具备的素质和属性,
另一方面提取公司所传达的自身素质和抱负。
应忽略任何样板文字或联系信息。尽可能减少整体
文字量。我们正在寻找招聘广告的精髓。

无需工具即可调用代理

claude-3–5-sonnet-20240620我们至少需要模型名称(例如)、提示和 Anthropic 客户端才能向 Anthropic API 发送请求以执行 LLM。招聘广告质量提取器代理拥有这一切。因此,它可以实例化并执行“裸机”代理类型。

裸机代理无需任何先前使用过的记忆或任何其他功能,只需调用一次 LLM。其关注的范围是 Anthropic 如何格式化其输入和输出。

我也创建了一个抽象基类Agent。它不是严格要求的,对于像 CV 创建这样基本的任务来说用处有限。但是,如果我们要继续在此基础上构建以处理更复杂和多样化的任务,抽象基类是一种很好的做法

from abc import ABC, abstractmethod

from anthropic import Anthropic
from anthropic.types import (
    ToolUseBlock,
    TextBlock,
    MessageParam,
)

from .semantics import send_request_to_anthropic_message_creation


class Agent(ABC):
    def __init__(self,
                 instruction: str,
                 client: Anthropic,
                 model: str = 'claude-3-haiku-20240307',
                 temperature: float = 1.0,
                 max_tokens: int = 4096,
                 ):
        self.anthropic_client = client
        self.system_instruction = instruction
        self.model = model
        self.temperature = temperature
        self.max_tokens = max_tokens

    @abstractmethod
    def run(self, text: str):
        pass


class AgentBareMetal(Agent):
    """A bare metal agent, which communicates directly with the LLM. No memory is kept.
    Args:
        instruction: The instruction to the LLM.
        client: The Anthropic client.
        model: The model to use.
        temperature: The temperature for sampling.
        max_tokens: The maximum number of tokens to generate.
    """
    def __init__(self,
                 instruction: str,
                 client: Anthropic,
                 model: str = 'claude-3-haiku-20240307',
                 temperature: float = 1.0,
                 max_tokens: int = 4096,
                 ):
        super().__init__(
            instruction=instruction,
            client=client,
            model=model,
            temperature=temperature,
            max_tokens=max_tokens,
        )

    def run(self, text: str) -> str:
        """Pass text to the LLM and return the response. No memory is kept.
        """
        messages = [
            MessageParam(
                content=text,
                role='user',
            )
        ]
        response = send_request_to_anthropic_message_creation(
            client=self.anthropic_client,
            messages=messages,
            system=self.system_instruction,
            model=self.model,
            max_tokens=self.max_tokens,
            temperature=self.temperature,
        )
        response_message = response.content[0]
        if not isinstance(response_message, TextBlock):
            raise ValueError(f'Unexpected response type: {type(response_message)}')

        return response_message.text

send_request_to_anthropic_message_creation对 Anthropic API 调用的简单包装器

这就是获取招聘广告摘要所需的全部内容。简而言之,步骤如下:

  1. 实例化招聘广告质量提取代理,这需要收集相关提示和人择模型参数。
  2. 使用公司名称和职位调用招聘广告数据访问对象以获取完整的招聘广告文本。
  3. 对完整的招聘广告文本进行提取,这需要向 Anthropic LLM 的 API 发出一次性请求;将返回一个包含生成的摘要的文本字符串。

就脚本中的代码而言make_cv,这些步骤如下:

 # Step 0: Get Anthropic client# Step 0: Get Anthropic client
    anthropic_client = get_anthropic_client(api_key_env)

    # Step 1: Extract key qualities and attributes from job ad
    ad_qualities = JobAdQualityExtractor(
        client=anthropic_client,
    ).extract_qualities(
        text=JobAdsDAO().get(job_ad_company, job_ad_title),
    )

数据流图的顶部已经描述完毕。

如何构建使用工具的代理

代理工作流中的所有其他类型的代理都使用工具。如今,大多数 LLM 都具备这种有用的能力。由于我上面描述了裸机代理,因此接下来我将描述使用工具的代理,因为它是后续许多内容的基础。

LLM 通过序列到序列映射生成字符串数据。在聊天应用程序以及招聘广告质量提取器中,字符串数据(大部分)是文本。

但是字符串数据也可以是函数参数的数组。例如,如果我有一个可执行函数,add它将两个整数变量a和相加,b并返回它们的和,那么要运行的字符串数据add可能是:

{ 
  “名称” : “添加” ,
  “输入” : {   
    “a” : “2” ,
    “b” : “2” 
  } 
}

因此,如果 LLM 输出这串函数参数,它可以在代码中导致函数调用add(a=2, b=2)

问题是:应如何指导 LLM 使其知道何时以及如何生成这种类型和特定语法的字符串数据?

除了AgentBareMetal代理之外,我还定义了另一种代理类型,它也继承了Agent基类:


            model=model,
            temperature=temperature,
            max_tokens=max_tokens,
        )
        self.tool_choice = {'type': 'any'}
        self.tools = []
        with open(tools_cv_data) as f:
            cv_data = json.load(f)
        for tool in tools:
            if tool in cv_data:
                self.tools.append(cv_data[tool])
            else:
                raise ValueError(f'Tool "{tool}" not found in CV data or functions')

    def run(self, text: str) -> Dict[str, Any]:
        """Bla bla
        """
        messages = [
            MessageParam(
                content=text,
                role='user',
            )
        ]
        response = send_request_to_anthropic_message_creation(
            client=self.anthropic_client,
            messages=messages,
            system=self.system_instruction,
            model=self.model,
            tools=self.tools,
            tool_choice=self.tool_choice,
            max_tokens=self.max_tokens,
            temperature=self.temperature,
        )
        tool_outs = {}
        for response_message in response.content:
            assert isinstance(response_message, ToolUseBlock)

            tool_name = response_message.name
            func_kwargs = response_message.input
            tool_id = response_message.id

            tool = registry_tool_name_2_func.get(tool_name)
            try:
                tool_return = tool(**func_kwargs)
            except Exception as e:
                raise RuntimeError(f'Error in tool "{tool_name}" with ID "{tool_id}": {e}')
            tool_outs[tool_id] = tool_return

        return tool_outs

这与裸机代理在两个方面有所不同:

  • self.tools是在实例化期间创建的列表。
  • tool_return是在执行过程中通过调用从注册表获得的函数创建的registry_tool_name_2_func

前一个对象包含指示 Anthropic LLM 的数据,该数据可作为不同工具的输入参数生成字符串数据的格式。后一个对象通过执行工具并给定 LLM 生成的字符串数据而产生。

tools_cv_data文件包含一个 JSON 字符串,该字符串的格式用于定义函数接口(但不定义函数本身)。该字符串必须符合非常具体的模式,以便 Anthropic LLM能够理解它。此 JSON 字符串的片段如下:

{
  "biography": {
    "name": "create_biography",
    "description": "Create biographical data of the person, including contact information, links to social media profiles (if available), and a free text description of the person.",
    "input_schema": {
      "type": "object",
      "properties": {
        "name": {
          "type": "string",
          "description": "Full name of the person."
        },
        "email": {
          "type": "string",
          "description": "Email address of the person."
        },
        "about_me": {
          "type": "string",
          "description": "Free text description of the person, akin to a section like 'about me' or 'career objetive' or 'mission statement', e.g. 'In pursuit of excellence in computational chemistry, over ten years of proven scientific team leadership.'"
        },
        "phone": {
          "type": "string",
          "description": "Phone number of the person."
        },
        "linkedin_url": {
          "type": "string",
          "description": "URL to the LinkedIn profile of the person."
        },
        "github_url": {
          "type": "string",
          "description": "URL to the GitHub profile of the person."
        },
        "blog_url": {
          "type": "string",
          "description": "URL to the personal blog of the person."
        },
        "home_address": {
          "type": "string",
          "description": "Home address of the person."
        }
      },
      "required": ["name", "email", "about_me"]
    }
  }
}

从上面的规范中我们可以看出,例如,如果的初始化在参数中AgentToolInvokeReturn包含字符串,那么 Anthropic LLM 将被指示它可以为名为的函数生成函数参数字符串。每个参数中应包含哪种数据留给 LLM 从 JSON 字符串中的描述字段中确定。因此,这些描述是小提示,可指导 LLM 进行理解。biographytoolscreate_biography

与本规范相关的功能我通过以下两个定义来实现。

from .registry import Registry
from .cv_data import (
    Biography,
)

registry_tool_name_2_func = Registry({
    'create_biography': Biography.build
}, read=False)
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional


@dataclass
class CVData(ABC):
    @staticmethod
    @abstractmethod
    def build(**kwargs) -> 'CVData':
        pass

@dataclass
class Biography(CVData):
    """Biography data class
    """
    name: str
    email: str
    about_me: str
    phone: Optional[str] = None
    linkedin_url: Optional[str] = None
    github_url: Optional[str] = None
    blog_url: Optional[str] = None
    home_address: Optional[str] = None

    @staticmethod
    def build(**kwargs) -> 'Biography':
        return Biography(**kwargs)

简而言之,工具名称create_biography与类构建器函数相关联Biography.build,该函数创建并返回数据类的实例Biography

请注意,数据类的属性完美地反映在添加到self.tools代理变量的 JSON 字符串中。这意味着从 Anthropic LLM 返回的字符串将完美地适合数据类的类构建器函数。

总而言之,仔细看看下面再次显示的run方法的内部循环:AgentToolInvokeRetur

for response_message in response.content:
            assert isinstance(response_message, ToolUseBlock)

            tool_name = response_message.name
            func_kwargs = response_message.input
            tool_id = response_message.id

            tool = registry_tool_name_2_func.get(tool_name)
            try:
                tool_return = tool(**func_kwargs)
            except Exception:
                ...

步骤如下:

  1. 检查 Anthropic LLM 的响应是否为函数参数字符串,而不是普通文本。
  2. 收集工具的名称(例如create_biography)、函数参数的字符串以及唯一的工具使用 ID。
  3. 从注册表中检索可执行工具(例如Biography.build)。
  4. 该函数使用字符串函数参数执行(检查错误)

一旦我们获得了工具的输出,我们就应该决定如何处理它。一些应用程序将工具输出集成到消息中,并向 LLM API 执行另一个请求。但是,在当前应用程序中,我构建了生成数据对象的代理,特别是的子类CVData。因此,我设计代理来调用该工具,然后简单地返回其输出——因此类名为AgentToolInvokeReturn

正是在这个基础上,我构建了代理,创建了我想要成为 CV 一部分的受限数据结构。

结构化简历数据提取代理

生成结构化简历数据的代理的类图如下所示。它与之前从招聘广告中提取品质的代理的类图有很多共同之处。

在代码中:

from abc import ABC, abstractmethod
from typing import Dict

from anthropic import Anthropic

from .confs import agent_model_extractor_conf
from .prompt_maker import get_prompt_for_
from .agent_base import AgentToolInvokeReturn
from .cv_data import (
    CVData,
    Biography,
)
from .tools import registry_cv_data_type_2_tool_key


class CVDataExtractor(ABC):
    """Base class for CV data extractors
    """
    @abstractmethod
    def __init__(self, *args, **kwargs):
        pass

    @property
    @abstractmethod
    def cv_data(self) -> CVData:
        pass

    @abstractmethod
    def __call__(self, text: str) -> Dict[str, CVData]:
        pass

      
class BiographyCVDataExtractor(CVDataExtractor):
    """Agent that generates biography summary for person
    """
    cv_data = Biography

    def __init__(self,
                 client: Anthropic,
                 relevant_qualities: str,
                 n_words_about_me: int,
                 ):
        self.agent = AgentToolInvokeReturn(
            client=client,
            tools=registry_cv_data_type_2_tool_key.get(self.cv_data),
            instruction=get_prompt_for_(
                agent_type=self.__class__.__name__,
                relevant_qualities=relevant_qualities,
                n_words=str(n_words_about_me),
            ),
            **get_core_model_conf(self.__class__.__name__),
        )

    def __call__(self, text: str) -> Dict[str, Biography]:
        return self.agent.run(text)

与前一个代理有两点区别JobAdQualityExtractor

  1. 工具名称是根据类属性cv_data(上面代码片段中的第 47 行)的功能检索的。因此,当实例化带有工具的代理时,工具名称的序列由注册表给出,该注册表将 CV 数据类型(例如)与上面描述的 JSON 字符串Biography中使用的键(例如)相关联。tools_cv_databiography
  2. 代理的提示使用变量呈现(第 48-52 行)。回想一下上面使用 Jinja 模板的情况。这可以注入与职位广告相关的品质以及“关于我”部分要使用的目标字数。传记代理的具体模板是:
传记提取代理的提示模板图像,注意两个变量

     这意味着,在实例化时,代理会了解其应该根据哪个招聘广告来定制其文本输出。

因此,当它接收到原始文本数据时,它会执行指令并返回数据类的实例Biography。出于相同的原因和类似的软件设计,我生成了额外的提取器代理和 CV 数据类和工具定义:

class EducationCVDataExtractor(CVDataExtractor):
    cv_data = Educations
    def __init__(self):
#      <truncated>

class EmploymentCVDataExtractor(CVDataExtractor):
    cv_data = Employments
    def __init__(self):
#      <truncated>

class SkillsCVDataExtractor(CVDataExtractor):
    cv_data = Skills
    def __init__(self):
#      <truncated>

现在我们可以将抽象提升一个层次。有了提取代理,它们应该与原始数据相结合,从中提取、总结、重写和提炼出简历数据内容。

数据检索和提取的编排

接下来要解释的数据图部分是突出显示的部分。

原则上,我们可以让提取器代理访问我们为其制作简历的人的所有可能的文本。但这意味着代理必须处理大量与其关注的特定部分无关的数据,例如,在个人意识流博客中几乎找不到正式的教育细节。

这就是检索和搜索的重要问题通常进入 LLM 驱动的应用程序的设计考虑的地方。

我们是否尝试找到相关的原始数据来应用我们的代理,还是将我们拥有的一切都投入到大型上下文窗口中,让 LLM 整理检索问题?很多人 对此事发表 自己的看法 。这是一个值得辩论的问题,因为以下说法有很多道理:

对于我的应用程序,我将保持其简单 — — 检索和搜索留到另一天。

因此,我将使用半结构化的原始数据。虽然我们对各个文档的内容有大致的了解,但从内部来看,它们主要由非结构化文本组成。这种情况在许多现实世界中很常见,在这些情况下,可以从文件系统或数据湖上的元数据中提取有用的信息。

检索难题的第一部分是模板目录的数据访问对象 (DAO)。其核心是这样的 JSON 字符串:

它将 CV 模板的名称(例如single_column_0)与所需数据部分的列表(CVData前面部分中描述的数据类)相关联。

接下来,我对哪个原始数据访问对象应与哪个 CV 数据部分进行编码。在我的示例中,我有一个适度的原始数据源集合,每个都可以通过 DAO 访问,例如PersonsEmploymentDAO

{
  "test_template": {
    "description": "Test CV template, only used in testing.",
    "required_cv_data_types": [
      "Educations"
    ]
  },
  "single_column_0": {
    "description": "Basic CV template with a single column layout, educational description details included.",
    "required_cv_data_types": [
      "Employments",
      "Educations",
      "Biography",
      "Skills"
    ]
  },
  "two_columns_abt_0": {
    "description": "Basic CV template with a two column layout, educational description details excluded, an 'about me' section included.",
    "required_cv_data_types": [
      "Employments",
      "Educations",
      "Biography",
      "Skills"
    ]
  }
}

请注意,此代码中的个人简介和技能简历数据是从多个原始数据源创建的。如果有其他原始数据源可用,则可以轻松修改这些关联(将新的 DAO 附加到元组)或在运行时进行配置。

接下来就是将原始数据和每个所需 CV 部分的 CV 数据提取器代理进行匹配。这就是编排器实现的数据流。下图是执行的放大数据流图CVDataExtractionOrchestrator

在代码中,编排器如下:

def _filter_kwargs(func: Callable, kwargs: Dict) -> Dict:
    """Filter keyword arguments to match the function signature"""
    return {
        k: v for k, v in kwargs.items()
        if k in signature(func).parameters
    }


class CVDataExtractionOrchestrator:
    """Orchestrate the extraction of CV data from particular data sources
    The extraction process invokes a CV data extractor agent to process the raw data
    associated with a particular data key.
    Args:
        client: The Anthropic client.
        **kwargs: Additional keyword arguments to pass to the CV data extractor agents.
            These can be overridden by the keyword arguments passed to the `run` method.
    """
    def __init__(self,
                 client: Anthropic,
                 **kwargs,
                 ):
        self.client = client
        self.kwargs = kwargs

    def run(self,
            cv_data_type: str,
            data_key: Any,
            **kwargs,
            ) -> Dict[str, CVData]:
        """Run the extraction process
        Args:
            cv_data_type (str): The type of CV data to extract.
            data_key (Any): The key to the raw data.
            **kwargs: Additional keyword arguments to pass to the CV data extractor agents.
        Returns:
            Dict[str, CVData]: The extracted CV data, keyed on a unique identifier.
        """
        try:
            raw_data_dao = _map_extractor_daos[cv_data_type]
        except KeyError:
            raise ValueError(f'CV data type "{cv_data_type}" not found in extractor daos')
        text = '\n\n'.join([
            dao().get(data_key) for dao in raw_data_dao
        ])

        try:
            cv_data_extractor_agent = _map_extractor_agents[cv_data_type]
        except KeyError:
            raise ValueError(f'CV data type "{cv_data_type}" not found in extractor agents')

        filtered_kwargs = _filter_kwargs(cv_data_extractor_agent.__init__, {**self.kwargs, **kwargs})
        cv_agent_instance = cv_data_extractor_agent(
            client=self.client,
            **filtered_kwargs
        )

        return cv_agent_instance(text=text)

把所有这些放到脚本里,make_cv我们有:

    # Step 2: Ascertain the data sections required by the CV template and collect the data
    cv_data_orchestrator = CVDataExtractionOrchestrator(
        client=anthropic_client,
        relevant_qualities=ad_qualities,
        n_words_employment=n_words_employment,
        n_words_education=n_words_education,
        n_skills=n_skills,
        n_words_about_me=n_words_about_me,
    )
    template_required_cv_data = FormTemplatesToCDAO().get(cv_template, 'required_cv_data_types')
    cv_data = {}
    for required_cv_data in template_required_cv_data:
        cv_data.update(cv_data_orchestrator.run(
            cv_data_type=required_cv_data,
            data_key=person_name
        ))

因此,对 Anthropic LLM 的调用是在协调器内进行的。每次调用都使用以编程方式创建的指令提示进行,通常包括招聘广告摘要、一些关于简历部分应有多长的参数,以及以人员姓名为关键字的原始数据。

一旦所有使用工具的代理都完成了其任务,循环就会产生一个结构化的 CV 数据类实例集合。

间奏:无、<未知>、“失踪”

Anthropic LLM 非常擅长将其生成的内容与构建数据类所需的输出模式进行匹配。例如,我不会偶尔在电子邮件字段中得到电话号码,不会想出无效的密钥,这会破坏数据类的构建功能。

但当我进行测试时,我发现了一个缺陷。

再看一下传记简历数据是如何定义的:

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional


@dataclass
class CVData(ABC):
    @staticmethod
    @abstractmethod
    def build(**kwargs) -> 'CVData':
        pass

@dataclass
class Biography(CVData):
    """Biography data class
    """
    name: str
    email: str
    about_me: str
    phone: Optional[str] = None
    linkedin_url: Optional[str] = None
    github_url: Optional[str] = None
    blog_url: Optional[str] = None
    home_address: Optional[str] = None

    @staticmethod
    def build(**kwargs) -> 'Biography':
        return Biography(**kwargs)

例如,如果 LLM 在某人的原始数据中未找到 GitHub URL,则允许返回None该字段,因为数据类中的该属性是可选的。这就是我想要的,因为它使最终简历的呈现更简单(见下文)。

但 LLM 通常会返回一个字符串值,通常是'<UNKNOWN>'。对于人类观察者来说,这是什么意思是毫无疑问的。这不是幻觉,因为它是一种看似真实但缺乏原始数据依据的虚构。

然而,对于使用简单条件逻辑的渲染算法来说,这是一个问题,例如 Jinja 模板中的情况:

  < div  class = "contact-info" >
         {{ biography.email }} 
        {% if biography.linkedin_url %} —  < a  href = "{{ biography.linkedin_url }}" > LinkedIn </ a > {% endif %} 
        {% if biography.phone %} — {{ biography.phone }}{% endif %} 
        {% if biography.github_url %} —  < a  href = "{{ biography.github_url }}" > GitHub </ a > {% endif %} 
        {% if biography.blog_url %} —  < a  href = "{{ biography.blog_url }}" >博客</ a > {% endif %} 
    </ div >

语义上对人类来说显而易见,但语法上却很混乱的问题,非常适合 LLM 来处理。在 LLM 之前,不一致的标签导致了许多麻烦,并产生了大量创造性的字符串匹配命令(任何做过包含许多自由文本字段的数据库数据迁移的人都可以证明这一点)。

因此,为了解决不完善的问题,我创建了另一个代理,对另一个 CV 数据提取器代理的输出进行操作。

class ClearUndefinedCVDataEntries:
    """Agent that clears undefined CV data entries. Deals with the LLM issue where some
    missing entries are labelled as 'UNKNOWN', 'undefined', etc., rather than being left
    blank.
    Args:
        client: The Anthropic client.
        cv_type: The type of CV data to clear.
    """
    def __init__(self,
                 client: Anthropic,
                 cv_type: Type[CVData]
                 ):
        self.agent = AgentToolInvokeReturn(
            client=client,
            tools=registry_cv_data_type_2_tool_key.get(cv_type),
            instruction=get_prompt_for_(self.__class__.__name__),
            **get_core_model_conf(self.__class__.__name__)
        )

    def __call__(self, cv_data_collection: Dict[str, CVData]) -> Dict[str, CVData]:
        serialized = {k: serialize_cv_data(v) for k, v in cv_data_collection.items()}
        return self.agent.run(text=json.dumps(serialized))

      此代理使用前面几节中描述的对象。不同之处在于,它以 CV 数据类的集合作为输入,并被指示清空任何“值以某种方式标记为未知、未定义、未找到或类似”的字段(完整提示的一部分)。

创建联合代理。它首先执行传记简历数据的创建,如前所述。其次,它在前一个代理的输出上执行清除未定义的代理,以修复任何 <UNKNOWN> 字符串的问题。

class BiographyCVDataExtractorWithClearUndefined(CVDataExtractor):
    """Agent that generates biography summary for person, ensuring there are no undefined entries
    """
    cv_data = Biography

    def __init__(self,
                 client: Anthropic,
                 relevant_qualities: str,
                 n_words_about_me: int,
                 ):
        self.extractor = BiographyCVDataExtractor(
            client=client,
            relevant_qualities=relevant_qualities,
            n_words_about_me=n_words_about_me,
        )
        self.clear_undefined = ClearUndefinedCVDataEntries(
            client=client,
            cv_type=self.cv_data,
        )

    def __call__(self, text: str) -> Dict[str, Biography]:
        return self.clear_undefined(self.extractor(text))

这个代理解决了这个问题,因此我在编排中使用它。

能否通过不同的指令提示来解决此缺陷?或者简单的字符串匹配修复是否足够?也许。

但是,我使用的是最简单、最便宜的 Anthropic LLM(haiku),而且由于代理采用模块化设计,因此很容易实现并附加到数据管道中。构建由多个其他代理组成的联合代理的能力是高级代理工作流使用的设计模式之一。

使用 CV 数据对象集合进行渲染

由于我们花了精力创建结构化且定义明确的数据对象,因此工作流程的最后一步相对简单。这些对象的内容通过语法匹配被明确地放置在 Jinja HTML 模板中。

例如,如果biography是 Biography CV 数据类的实例和envJinja 环境,则以下代码

template = env.get_template('test_template.html')
template.render(biography=biography)

test_template.html喜欢

< body > 
    < h1 > {{ biography.name }} </ h1 > 
    < div  class = “contact-info” >
       {{ biography.email }} 
    </ div > 
</ body >

匹配数据类的名称和电子邮件属性Biography并返回类似以下内容:

<body>
    <h1>{{ biography.name }}</h1>
    <div class="contact-info">
      {{ biography.email }}
    </div>
</body>

该函数populate_html获取所有生成的 CV 数据对象并使用 Jinja 功能返回 HTML 文件。

import os
from typing import Sequence, Tuple, Callable

from jinja2 import Environment, BaseLoader, TemplateNotFound

from .cv_data import CVData
from .dao import FormTemplatesDAO


class CustomTemplateLoader(BaseLoader):
    """Custom template loader for the jinja form templates which are accessed via the DAO
    See: https://tedboy.github.io/jinja2/generated/generated/jinja2.BaseLoader.html
    """
    def __init__(self, dao: FormTemplatesDAO):
        self.dao = dao

    def get_source(self, environment, template) -> Tuple[str, str, Callable]:
        try:
            source = self.dao.get(template)
        except KeyError:
            raise TemplateNotFound(template)
        template_path = self.dao.path(template)
        mtime = os.path.getmtime(template_path)
        return source, template_path, lambda: mtime == os.path.getmtime(template_path)

    def list_templates(self):
        return self.dao.keys()


env = Environment(loader=CustomTemplateLoader(dao=FormTemplatesDAO()))


def populate_html(
        template_name: str,
        cv_data: Sequence[CVData],
):
    """Construct the HTML file from the template and data
    Note that this function require that the template variables are named after the CVData classes
    in lower case. For example, if the CVData class is `Educations`, then the template variable
    should be `educations`. Individual attributes of the CVData object can be accessed using the
    dot notation. For example, `educations[0].school` will access the `school` attribute of the
    first `Educations` object in the sequence.
    Args:
        template_name (str): Name of the template file
        cv_data (Sequence[CVData]): Sequence of CVData objects
    """
    template = env.get_template(template_name)
    cv_data_objs = {cv_data_obj.__class__.__name__.lower(): cv_data_obj for cv_data_obj in cv_data}

    return template.render(**cv_data_objs)

因此,脚本中的make_cv第三步也是最后一步是:

# Step 3: Render the CV with data and template and save output
    html = populate_html(
        template_name=cv_template,
        cv_data=list(cv_data.values()),
    )
    with open(output_file, 'w') as f:
        f.write(html)

这样就完成了代理工作流程。原始数据已提炼,内容已放入结构化数据对象中,这些对象反映了标准简历的信息设计,内容已呈现在编码样式选择的 HTML 模板中。

那么简历模板呢——如何制作?

CV 模板是 HTML 文件的 Jinja 模板。因此,任何可以创建和编辑 HTML 文件的工具都可用于创建模板。只要变量命名符合 CV 数据类的名称,它就会与工作流兼容。

因此,例如,Jinja 模板的以下部分将从 CV 数据类的实例中检索数据属性Employments创建带有描述(由 LLM 生成)和持续时间数据(如果可用)的就业列表:

<h2>Employment History</h2>
            {% for employment in employments.employment_entries %}
            <div class="entry">
                <div class="entry-title">
                    {{ employment.title }} at {{ employment.company }} ({{ employment.start_year }}{% if employment.end_year %} - {{ employment.end_year }}{% endif %}):
                </div>
                {% if employment.description %}
                <div class="entry-description">
                    {{ employment.description }}
                </div>
                {% endif %}
            </div>
            {% endfor %}

我对前端开发了解甚少——多年来我编写的代码中甚至很少见到 HTML 和 CSS。

因此,我决定使用 LLM 来创建简历模板。毕竟,在这个任务中,我试图将人类观察者认为合理且直观的外观和设计映射到一串特定的 HTML/Jinja 语法中 — 事实证明,这类任务 LLM 非常擅长。

我选择不将其与代理工作流程集成,而是将其附加在数据流图的角落,作为应用程序的有用附录。

我使用了 Claude,这是 Anthropic 的 Sonnet LLM 的聊天界面。我为 Claude 提供了两样东西:一张图片和一个提示。

该图是我使用文字处理器快速制作的单列简历的粗略轮廓,然后进行屏幕转储。

用于指导 Claude 的单列简历布局的屏幕转储

我给出的提示相当长。它由三部分组成。

首先,我要说明一下我希望完成的任务,以及在 Claude 执行任务时我将向他提供哪些信息。

本节提示部分内容如下:

我希望为静态 HTML 页面创建一个 Jinja2 模板。该 HTML 页面将显示一个人的简历。该模板旨在使用 Python 以 Python 数据结构作为输入进行渲染。

第二,对布局进行口头描述。本质上是对上方图像的描述,从上到下,并附上字体相对大小、各部分的顺序等注释。

第三,我将使用数据结构来渲染 Jinja 模板。部分提示如下图所示:

提示继续列出所有 CV 数据类。

对于熟悉 Jinja 模板、HTML 和 Python 数据类的人类解释器来说,这些信息足以将布局中电子邮件放置位置的语义描述与{{ biography.email }}HTML Jinja 模板中的语法进行匹配,将布局中 LinkedIn 个人资料 URL(如果可用)放置位置的描述与语法进行匹配{% if biography.linkedin_url %} <a href=”{{ biography.linkedin_url }}”>LinkedIn</a>{% endif },等等。

Claude 完美地执行了任务——无需我手动编辑模板。

我使用单列模板和人物Gregor Samsa(稍后会介绍有关他的更多信息)的合成数据运行了代理工作流程。

make_cv --job-ad-company "epic resolution index" \
        --job-ad-title "luxury retail lighting specialist" \
        --cv-template single_column_0 \
        --person-name "gregor samsa" \
        --output-file ../generated_output/cv_single_column_0.html \
        --n-words-employment 50 --n-skills 8

输出文档:

一份不错的简历。但我想创造一些变化,看看克劳德和我能做出什么。

所以我又创建了一个提示和屏幕转储。这次是针对两栏简历。我起草的粗略大纲如下:

用于指导 Claude 的双栏简历布局的屏幕转储

我重新使用了单列的提示,只更改了第二部分,其中我用文字描述布局。

它再次完美地运行了。

不过,这种风格对我来说有点太平淡了。因此,作为对 Claude 的后续提示,我写道:

喜欢它!你能重做上一个任务但做一点修改吗:给它添加一些亮点和颜色。Arial 字体,黑白都有点无聊。我喜欢一点绿色和好看的字体。哇哦!当然,它看起来应该还是很专业的。

如果 Claude 生气地回复说我必须更具体一点,我就会感同身受(在某种意义上)。相反,Claude 的创意源源不断,创建了一个模板,渲染后看起来像这样:

好的!

值得注意的是,粗略大纲中的基本布局在此版本中得以保留:章节的放置、两栏的相对宽度以及教育条目中缺少描述等。只有风格有所改变,并且与给出的模糊规范一致。在我看来,克劳德的创作能力很好地填补了这些空白。

接下来,我探索了 Claude 是否能够在我将样式调到 11 级时保持模板布局和内容规范清晰一致。所以我在 Claude 旁边写道:

太棒了。但现在我要你全力以赴!我们正在谈论 20 世纪 90 年代早期的网页美学,闪烁的东西,在最奇怪的地方使用漫画字体,怪异而疯狂的色彩对比。全速前进,克劳德,玩得开心。

结果是辉煌的。

这个格里高尔·萨姆沙是谁,多么自由的思想家,没有一丝焦虑——雇用他吧!

即使采用这种极端的样式,指定的布局大部分都会保留,文本内容也是如此。有了足够详细的提示,Claude 似乎可以创建功能齐全且样式精美的模板,这些模板可以成为代理工作流程的一部分。

文本输出怎么样?

除了引人注目的风格和实用的布局之外,简历还必须包含简短的文字,简洁而真实地显示个人与职位的匹配度。

为了探索这个问题,我为Gregor Samsa创建了合成数据— — 他在欧洲中部接受教育,从事灯具销售工作,对昆虫学有着浓厚的兴趣。我生成了 Gregor 过去和现在的原始数据,一些来自我的想象,一些来自法学硕士学位。细节并不重要。关键是文本太混乱、太笨重,无法复制粘贴到简历中。必须找到数据(例如,电子邮件地址出现在 Gregor 的一般沉思中),进行总结(例如,对 Gregor 的博士工作的描述非常详细),提炼并根据相关职位进行定制(例如,哪些技能值得突出),并将所有内容缩减为关于我的一两句友好的句子。

文本输出效果非常好。我让 Anthropic 最先进、最有说服力的模型 Sonnet 编写了“关于我”部分。语气听起来很真实。

在我的测试中,我没有发现明显的幻觉。然而,法学硕士在技能部分做了一些改动。

原始数据中,Gregor 在布拉格和维也纳工作和学习,主要参加一些英语教育者的在线课程。在一份生成的简历中,列出了捷克语、德语和英语的语言技能,尽管原始数据没有明确声明这些知识。法学硕士对技能做出了合理的推断。不过,这些技能并不是仅从原始数据中抽象出来的。

所有代码和合成数据都可以在联系我的 GitHub repo中找到。我使用 Python 3.11 来运行它,只要您有 Anthropic 的 API 密钥(脚本假定该密钥存储在环境变量中ANTHROPIC_API_KEY),您就可以运行和探索该应用程序——当然,据我所知,没有错误,但我不保证。

本教程展示了一种使用生成式 AI 的方法,列举了生成式应用中有用的约束条件,并展示了如何直接使用 Anthropic API 来实现这一切。虽然 CV 创建不是一项高级任务,但我介绍的原理和设计可以作为其他具有更大价值和复杂性的非聊天应用程序的基础。

感谢关注雲闪世界。(亚马逊aws和谷歌GCP服务协助解决云计算及产业相关解决方案)

订阅频道(https://t.me/awsgoogvps_Host)
TG交流群(t.me/awsgoogvpsHost)

  • 11
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值