selmertsxの素振り日記

ひたすら日々の素振り内容を書き続けるだけの日記

Terraformを使ってTypeScriptのLambdaをDeployする方法

この資料の目的

この記事では Terraform で、TypeScript を利用した AWS Lambda を管理する方法を記載しています。 個人的な好みで言えば Lambda を IaC で扱う場合、AWS の提供してくれる CI/CD の機能・サービスに乗りやすい CDK の方が好みです。しかし、チームによっては terraform が望ましい場合もあります。

今回、Terraform で AWS Lambda のリソースを作成したので、後々忘れないように自分のための備忘録として残します。

前提情報

言葉の定義

数々存在しているインフラリソースを任意のまとまりを、この記事では「Stack」と呼ばせていただきます。

Lambda の目的・役割

今回作成する Lambda は S3 Bucket にファイルがアップロードされたとき、それを event として SQS に message を送信し、Lambda を起動します。今回詳細は書きませんが、Lambda は外部サービスを利用して得られた結果を DynamoDB に保存します。なお、ここでは S3 Bucket は別の stack で既に作成されているものとします。

Terraform コーディング規約

Terraform のコーディング規約は、基本的な部分では Terraform 公式のものを採用しつつ、細かい部分では GCP から提供されているものを採用しています。公式と GCP のコーディング規約では、1 点主張がコンフリクトしている部分があります。それは別のスタックで作成されたリソースの参照です。GCP では、下記のように terraform_remote_state の利用を推奨しています。

Terraform を使用するためのベスト プラクティス(GCP)

別の Terraform 構成で管理されているリソースのクエリに、データソースを使用しないでください。これを行うと、リソース名と構造に暗黙的な依存関係が作成され、通常の Terraform オペレーションで意図しない中断が発生する可能性があります。Terraform 構成間で情報を共有する場合は、リモート状態を使用して他のルート モジュールを参照することをおすすめします。

しかし、Terraform の公式では下記の文章を読むと data source の利用を推奨しているように見えます。

The terraform_remote_state Data Source(Terraform 公式)

Sharing data with root module outputs is convenient, but it has drawbacks. Although terraform_remote_state only exposes output values, its user must have access to the entire state snapshot, which often includes some sensitive information. When possible, we recommend explicitly publishing data for external consumption to a separate location instead of accessing it via remote state. This lets you apply different access controls for shared information and state snapshots.

ここは組織によって判断が分かれる部分でしょう。stack 毎に権限を明確に分離する必要がある組織では Terraform 公式の方針が望ましいように見えますし、そうでない組織では Google 公式の方針の方が、依存先の stack を明確に表現しやすいように見えます。ちなみに私は Terraform 公式の意見を採用しています。

IaC の Stack 設計

私は IaC の Stack の設計は基本的に下記のようにしています。

stack

基本的に、主役となる App Stack 以外は、すべて小さい Stack になるようにしています。設計の意図は私の過去の資料、チームみんなが参加しやすくなる IaC 設計の工夫に記載しています。

Directory 設計

GCP環境固有のサブディレクトリにアプリケーションを分割するにおいて、推奨される directory 設計は下記のようになっています。

-- SERVICE-DIRECTORY/
   -- modules/
      -- <service-name>/
         -- main.tf
         -- variables.tf
         -- ...
      -- ...other…
   -- environments/
      -- dev/
         -- backend.tf
         -- main.tf
      -- prod/
         -- backend.tf
         -- main.tf

これを参考として、Terraform の Directory 設計を下記のようにしました。 ちょっと module が小さすぎるという意見もあるかも知れませんが、自分はこれくらいの粒度で扱っています。

$ tree -L 2 lambda_sample
lambda_sample
├── Makefile
├── build # Lambdaをbuildしたファイルを置く場所
├── environments // Terraform の各環境の構成を置く場所
│   ├── development
│   ├── production
│   └── staging
├── modules // Terraform の modules
│   ├── dynamodb
│   ├── lambda
│   ├── parameters
│   └── queue
├── package.json
├── src  // Lambda のプログラムを置く場所
│   ├── lambda_sample
│   └── utils
├── tsconfig.json
└── yarn.lock

Terraform のプログラム

入口となる各環境ごとの main.tf ファイルを記載します。 s3 bucket からの event を受け取る queue (SQS)、Lambda の処理内容を保存する DynamoDB、Lambda で外部 SaaS を利用するための Parameter Store、そして Lambda そのものが定義されています。 DynamoDB や Parameter Store は特段変わったことはないので、ここでは SQS と Lambda の設定のみ記載します。

environemnts/${env}/main.tf はこちら

// environemnts/${env}/main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "5.42.0"
    }
  }

  required_version = "1.7.5"

  backend "s3" {
    bucket  = "sample-terraform-bucket"
    key     = "terraform-iac/terraform.tfstate"
    region  = "ap-northeast-1"
    encrypt = true
  }
}

module "queue" {
  source      = "../../modules/queue"
  bucket_name = "lambda-sample-${var.env}"
}

module "dynamodb" {
  source = "../../modules/dynamodb"
}

module "parameters" {
  source               = "../../modules/parameters"
  encrypted_sample_key = var.encrypted_sample_key
}

module "lambda" {
  source                       = "../../modules/lambda"
  package_filename             = var.package_filename
  sqs_event_source_arn         = module.queue.sqs_event_source_arn
  dynamodb_table_arn           = module.dynamodb.table_arn
  dynamodb_table_name          = module.dynamodb.table_name
  sample_key_arn               = module.parameters.sample_key_arn
}

Lambda module

Terraform から Lambda を設定する上で忘れちゃいけないことは、下記 2 点です。

  • source_code_hash に filebase64sha256 を設定すること
  • lambda layers に AWS 公式が提供する ParameterStore のキャッシュを入れること

source_code_hash の設定は、Lambda の設定が変更されたときのみ、Lambda を Deploy するために必要です。また、Lambda Layers に ParameterStore の設定をする理由は、公式ドキュメントのこちらを参考にしてください。

modules/lambda/main.tf

data "aws_caller_identity" "current" {}

resource "aws_iam_role" "this" {
  name               = "sample-lambda-role"
  description        = "IAM Role for Sample Lambda"
  assume_role_policy = file("${path.module}/lambda-role.json")
}

resource "aws_iam_policy" "this" {
  name        = "lambda-policy"
  description = "IAM Policy for Lambda"
  policy = templatefile("${path.module}/lambda-policy.json.tftpl", {
    bucket_name          = "sample bucket name",
    sample_key_arn       = var.sample_key_arn
    dynamodb_table_arn   = var.dynamodb_table_arn
  })
}

# Lambda自体のPOLICY
resource "aws_iam_role_policy_attachment" "custom_policy" {
  role       = aws_iam_role.this.name
  policy_arn = aws_iam_policy.this.arn
}

# SQSから起動できるようにするためのPolicy
resource "aws_iam_role_policy_attachment" "invoke_from_sqs" {
  role       = aws_iam_role.this.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole"
}

# NOTE: architecturesはARM64を採用する。安くて高速であり、現時点でデメリットも見えないため
# NOTE: ParameterStoreの値をキャッシュするLayerの設定は下記公式ドキュメントを参考にしている
# https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/ps-integration-lambda-extensions.html
resource "aws_lambda_function" "this" {
  filename         = var.package_filename
  function_name    = "sample-lambda"
  role             = aws_iam_role.this.arn
  handler          = "index.handler"
  source_code_hash = filebase64sha256(var.package_filename)
  runtime          = "nodejs20.x"
  architectures    = ["arm64"]
  layers           = ["arn:aws:lambda:ap-northeast-1:133490724326:layer:AWS-Parameters-and-Secrets-Lambda-Extension-Arm64:11"]
  timeout          = 60
  memory_size      = 256
}

resource "aws_lambda_event_source_mapping" "this" {
  event_source_arn = var.sqs_event_source_arn
  function_name    = aws_lambda_function.lambda.arn
}

lambda-role.json

// lambda-role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Effect": "Allow"
    }
  ]
}

lambda-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowS3ObjectRead",
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::${bucket_name}/*"],
      "Effect": "Allow"
    },
    {
      "Sid": "AllowKMSKeyDecrypt",
      "Action": ["ssm:GetParameter", "kms:Decrypt"],
      "Resource": ["${sample_key_arn}"],
      "Effect": "Allow"
    },
    {
      "Sid": "AllowDynamoDBTableRead",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:BatchGetItem",
        "dynamodb:Scan",
        "dynamodb:Query",
        "dynamodb:PutItem",
        "dynamodb:UpdateItem"
      ],
      "Resource": ["${dynamodb_table_arn}"],
      "Effect": "Allow"
    }
  ]
}

queue module

S3 のイベントをフックとして Lambda を起動する方法はいくつかあります。 最もシンプルな方法は、aws_s3_bucket_notification にて lambda_function の設定をすることでしょう。しかし、今回は DLQ の設定をしたかったので下記のような実装にしました。DLQ を受け取ったあとの処理については、ここでは割愛しています。

modules/queues/main.tf

data "aws_caller_identity" "current" {}

resource "aws_sqs_queue" "deadletter_queue" {
  name                       = "lambda-deadletter-queue"
  visibility_timeout_seconds = 60
  message_retention_seconds  = 604800
}

// DLQを受け取って何らかの処理をする機能がここに入る

resource "aws_sqs_queue" "lambda_invoke" {
  name                       = "lambda-queue"
  visibility_timeout_seconds = 60
  message_retention_seconds  = 345600
  policy = templatefile("${path.module}/lambda-queue-policy.json.tftpl", {
    account_id  = data.aws_caller_identity.current.account_id,
    bucket_name = var.bucket_name
  })
  redrive_policy = jsonencode({
    deadLetterTargetArn = aws_sqs_queue.deadletter_queue.arn
    maxReceiveCount     = 4
  })
}

resource "aws_s3_bucket_notification" "lambda_invoke" {
  bucket = var.bucket_name

  queue {
    queue_arn     = aws_sqs_queue.lambda_invoke.arn
    events        = ["s3:ObjectCreated:Put"]
    filter_prefix = "bucket内部の所定のprefix"
  }
}

lambda-queue-policy.json.tftpl

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "s3.amazonaws.com"
      },
      "Action": "SQS:SendMessage",
      "Resource": "arn:aws:sqs:ap-northeast-1:${account_id}:*",
      "Condition": {
        "ArnLike": {
          "aws:SourceArn": "arn:aws:s3:*:*:${bucket_name}"
        }
      }
    }
  ]
}

Lambda のプログラム

Lambda の実装について、特段特徴的な部分はありません。 1 点だけ上げるならば、ParameterStore の Layer はデフォルトでは localhost の port 2773 にてアクセスできます。なので、下記のようなプログラムを用意して ParameterStore にアクセスさせていました。

// Lambda Layerを利用して Parameter Store にアクセスする
// 公式ドキュメント: https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/ps-integration-lambda-extensions.html

import fetch from "node-fetch";
import type { GetParameterResult } from "@aws-sdk/client-ssm";

const ENDPOINT = "http://localhost:2773/systemsmanager/parameters/get/?name=";

export class ParameterStore {
  private sessionToken: string;

  constructor() {
    this.sessionToken = process.env.AWS_SESSION_TOKEN as string;
  }

  async getParameter(
    parameter: string,
    withDecryption: boolean = false // SecureStringの場合はtrue
  ): Promise<string> {
    const requestURL = `${ENDPOINT}${this.encodedParameter(
      parameter
    )}&withDecryption=${withDecryption}`;
    const response = await fetch(requestURL, {
      method: "GET",
      headers: { "X-Aws-Parameters-Secrets-Token": this.sessionToken },
    });
    const json = (await response.json()) as GetParameterResult;
    if (json?.Parameter?.Value === undefined) {
      throw new Error(`Parameter is not found. parameter: ${parameter}`);
    }

    return json.Parameter.Value;
  }

  private encodedParameter(parameter: string) {
    return encodeURIComponent(`${parameter}`);
  }
}

handler.ts

import type { SQSEvent, SQSHandler } from "aws-lambda";
export const handler: SQSHandler = async (event: SQSEvent): Promise<any> => {
  const record = getS3EventRecord(event);
  if (record === undefined) return;
  /* 
    ここに色んな処理を書く
   */
  return;
};

tsconfig.json

// tsconfig.json
{
  "compilerOptions": {
    "lib": ["esnext"],
    "target": "es2020",
    "module": "commonjs",
    "rootDir": "./src/",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "moduleResolution": "node"
  },
  "ts-node": {
    "esm": true,
    "experimentalSpecifierResolution": "node"
  }
}

package.json

{
  "name": "sample_lambda",
  "version": "1.0.0",
  "main": "index.js",
  "license": "UNLICENSED",
  "devDependencies": {
    "@types/aws-lambda": "^8.10.130",
    "@types/jest": "^29.5.10",
    "@types/node": "^20.10.4",
    "@types/prettier": "^3.0.0",
    "esbuild": "^0.19.8",
    "ts-node": "^10.9.2",
    "typescript": "^5.3.2"
  },
  "dependencies": {
    "@aws-sdk/client-dynamodb": "^3.470.0",
    "@aws-sdk/client-s3": "^3.465.0",
    "@aws-sdk/client-ssm": "^3.465.0",
    "node-fetch": "^3.3.2"
  }
}

実行ファイル

terraform の操作は make コマンドを使ってます。 環境毎の IAM Role の switch を aws-vault を使って行いたいので、諸々 Makefile にまとめてます。

# Makefile
include ../base-terraform.mk

BUILD=$(shell realpath build)
PACKAGE=$(BUILD)/lambda.zip

.PHONY: zip
# プログラムに差分がないときデプロイされないようにtimestampを固定する
zip: ## プログラムをzipする
    find ${BUILD} -exec touch -t 197001010000.00 {} \;
        cd ${BUILD} && zip -X -r lambda.zip index.js

.PHONY: build
build: ## ビルドする
    yarn esbuild --bundle src/sample_lambda/handler.ts --format=cjs --target=node20 --outfile=${BUILD}/index.js --platform=node

.PHONY: apply
apply: build zip ## terraformをAPPLYする
    cd environments/$(ENV) && $(AWS_VAULT_EXEC) -- terraform apply $(if $(TARGET),-target=$(TARGET),) -var package_filename=${PACKAGE}

base-terraform.mk

# ../base-terraform.mk
# AWS Profileを環境毎に出し分ける設定
AWS_VAULT_PROFILE_production ?= selmertsx-prod
AWS_VAULT_PROFILE_staging ?= selmertsx-stg
AWS_VAULT_PROFILE_development ?= selmertsx-dev
AWS_VAULT_PROFILE ?= $(AWS_VAULT_PROFILE_$(ENV))
AWS_VAULT_EXEC ?= aws-vault exec $(AWS_VAULT_PROFILE) --duration=12h

.PHONY: correct
correct: ## 静的解析によるコードの自動修正を実行
    terraform fmt -diff -recursive
    prettier --write './**/*.{yml,yaml,md,json}'

.PHONY: init
init: ## terraformの初期化をします
    cd environments/$(ENV) && $(AWS_VAULT_EXEC) -- terraform init -upgrade

.PHONY: plan
plan: ## terraformの差分を表示します
    cd environments/$(ENV) && $(AWS_VAULT_EXEC) -- terraform plan $(if $(TARGET),-target=$(TARGET),)

.PHONY: apply
apply: ## terraformを実行します
    cd environments/$(ENV) && $(AWS_VAULT_EXEC) -- terraform apply $(if $(TARGET),-target=$(TARGET),)

.PHONY: destroy
destroy: ## terraformのリソースを削除します
    cd environments/$(ENV) && $(AWS_VAULT_EXEC) -- terraform destroy $(if $(TARGET),-target=$(TARGET),)

「ChatGPT/LangChainによるチャットシステム構築 」という書籍が素晴らしかったのでNode.jsでも書いてみた

はじめに

「ChatGPT/LangChainによるチャットシステム構築」 という本が素晴らしかったので、ちゃんと身につけるために Python だけじゃなくて Node.js でも動かしてみました。同じことをやろうとした人のために、ここにそのときの記録を残します。特に callbacksやmemoryについて、詳細に記載しようと思います。

書籍の説明につながるようなことはできる限り書きません!めっちゃ良書なので、ご興味持っていただけた方は購入してもらえますと 🙏

5章まではPython固有のToolを利用しており、6章の中身は7章とかなり近いところがあるので、7章のプログラムだけここに記載します。LangChainの学習に注力したいので、Serverelss Frameworkに関連するコードは省略しました。また、Momentoや @slack/bolt に関する説明はしません。

プログラムの全体像

今回作成したプログラムの全体像は下記のとおりです。

src
├── handler.ts
├── history.ts
└── index.ts
// index.ts
import bolt from "@slack/bolt";
import { ChatOpenAI } from "langchain/chat_models/openai";
import SlackBotCallbackHandler from "./handler";
import { createHistory } from "./history";

const { App } = bolt;

const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  appToken: process.env.SLACK_APP_TOKEN,
  socketMode: true,
  port: 3000,
});

function CreateLLM(handler: SlackBotCallbackHandler) {
  return new ChatOpenAI({
    modelName: "gpt-4-1106-preview",
    temperature: 0,
    streaming: true,
    callbacks: [handler],
  });
}

app.event("app_mention", async ({ event, say }) => {
  const ts = event.thread_ts || event.ts;
  const botTypingMessage = await say({ thread_ts: ts, text: "Typing..." });
  if (!botTypingMessage.ts) return;

  /* create LLM Settings */
  const handler = new SlackBotCallbackHandler({
    channel: event.channel,
    ts: botTypingMessage.ts,
    app: app,
  });
  const llm = CreateLLM(handler);

  /* reply & add history */
  const histroy = await createHistory(event.channel);
  const userMessage = event.text.replace(/<@.*>/, "");
  await histroy.addUserMessage(userMessage);
  const aiMessage = await llm.predict(event.text.replace(/<@.*>/, ""));
  await histroy.addAIChatMessage(aiMessage);
});

(async () => {
  await app.start();
})();
// handler.ts
import { BaseCallbackHandler } from "langchain/callbacks";

type Props = {
  channel: string;
  ts: string;
  app: any;
};

export default class SlackBotCallbackHandler extends BaseCallbackHandler {
  name = "SlackBotCallbackHandler";

  private channel: string;
  private ts: string;
  private message: string = "";
  private lastTime: number = 0;
  private app: any;

  constructor({ channel, ts, app }: Props) {
    super();
    this.channel = channel;
    this.ts = ts;
    this.app = app;
  }

  handleLLMNewToken(token: string) {
    this.message = this.message + token;

    const currentTime = new Date().getTime();
    if (currentTime - this.lastTime > 2000 && token) {
      this.lastTime = currentTime;
      this.app.client.chat.update({
        channel: this.channel,
        ts: this.ts,
        text: this.message,
      });
    }
  }

  handleLLMEnd() {
    this.app.client.chat.update({
      channel: this.channel,
      ts: this.ts,
      text: this.message,
    });
  }
}
// history.ts
import { MomentoChatMessageHistory } from "langchain/stores/message/momento";
import {
  CacheClient,
  Configurations,
  CredentialProvider,
} from "@gomomento/sdk";

export function createHistory(sessionId: string) {
  return MomentoChatMessageHistory.fromProps({
    sessionId,
    cacheName: process.env.MOMENTO_CACHE as string,
    client: new CacheClient({
      configuration: Configurations.Laptop.v1(),
      credentialProvider: CredentialProvider.fromEnvironmentVariable({
        environmentVariableName: "MOMENTO_AUTH_TOKEN",
      }),
      defaultTtlSeconds: 60 * 60,
    }),
  });
}

LangChainの学習要素

7章において、callbacks と memory の2つの要素が出てきたので、この2点に注力して詳細を追っていこうと思います。

Callbacks

https://js.langchain.com/docs/modules/callbacks/

LangChainでは、Chain, LLM, Tool, Agent などのコンポーネントに対して Callbackを設定することができます。一番シンプルな使い方は下記のようになります。

// 公式より抜粋
import { OpenAI } from "langchain/llms/openai";
const model = new OpenAI({ maxTokens: 25, streaming: true });
const response = await model.call("Tell me a joke.", {
  callbacks: [{handleLLMNewToken(token: string){ console.log({ token })}},
});

上記のケースでは、LLMが新しい token を受け取ったときに console.log({token}) の処理を実行します。この機能を使えばリアルタイムに出力を表示したり、様々なlogを仕込んだりすることができるでしょう。今回のケースでは、下記のように新しい token を取得した際にメッセージを更新しています。

// 簡単のために一部省略
  handleLLMNewToken(token: string) {
    this.message = this.message + token;
    this.app.client.chat.update({
      channel: this.channel,
      ts: this.ts,
      text: this.message,
    });
  }

LangChain本体でのCallbackの利用

CallbackはLangChain本体でも使われています。例えば verboseオプションなどはConsoleCallbackHandlerを仕込んでいます。

// https://github.com/langchain-ai/langchainjs/blob/main/langchain-core/src/callbacks/manager.ts#L483
if (verboseEnabled || tracingEnabled) {
  if (!callbackManager) {
    callbackManager = new CallbackManager();
  }
  if (
    verboseEnabled &&
    !callbackManager.handlers.some(
      (handler) => handler.name === ConsoleCallbackHandler.prototype.name
    )
  ) {
    const consoleHandler = new ConsoleCallbackHandler();
    callbackManager.addHandler(consoleHandler, true);
  }

ConsoleCallbackHandlerのプログラムを一部抜粋したものがこちらです。console.logにて入力した情報などを出力している処理が見て取れます。

// https://github.com/langchain-ai/langchainjs/blob/main/langchain-core/src/tracers/console.ts#L154
onLLMStart(run: Run) {
  const crumbs = this.getBreadcrumbs(run);
  const inputs =
    "prompts" in run.inputs
      ? { prompts: (run.inputs.prompts as string[]).map((p) => p.trim()) }
      : run.inputs;
  console.log(
    `${wrap(
      color.green,
      "[llm/start]"
    )} [${crumbs}] Entering LLM run with input: ${tryJsonStringify(
      inputs,
      "[inputs]"
    )}`
  );
}

Pythonの方は様々なcallbackのintegrationsがありそうですが、Node.js の方はあんまりなそう?(勘違いだったらすみません!)なので、足りなければ基本的に自作する必要がありそうです。

Memory

https://js.langchain.com/docs/modules/memory/

LangChainのMemoryは、過去の会話を覚えておくためのモジュールです。

公式の一番シンプルな例を持ってくると下記のようになります。

import { ChatMessageHistory } from "langchain/memory";
const history = new ChatMessageHistory();
await history.addUserMessage("Hi!");
await history.addAIChatMessage("What's up?");
const messages = await history.getMessages();

今回のコードでは下記のように利用しています。

// history.ts
import { MomentoChatMessageHistory } from "langchain/stores/message/momento";
import {
  CacheClient,
  Configurations,
  CredentialProvider,
} from "@gomomento/sdk";

export function createHistory(sessionId: string) {
  return MomentoChatMessageHistory.fromProps({
    sessionId,
    cacheName: process.env.MOMENTO_CACHE as string,
    client: new CacheClient({
      configuration: Configurations.Laptop.v1(),
      credentialProvider: CredentialProvider.fromEnvironmentVariable({
        environmentVariableName: "MOMENTO_AUTH_TOKEN",
      }),
      defaultTtlSeconds: 60 * 60,
    }),
  });
}

7章においては、LLMの回答の生成にhistoryが活用されていないため、どういったアウトカムにつながるのかがイメージができないと思います。ぱっと、公式ドキュメントを読んだ感想としては、Prompt, Memory, History の役割は、RDBMSにおけるクエリー、クエリエンジン、テーブルっぽいという印象を受けました。そのあたりの詳細は8章にて、ちゃんと調査してまとめようと思います。

LangChainjsのライブラリにおけるhistory実装の仕組み

ライブラリを読んでみたところ実装は下記のようになっていました。 少し前のプログラムでclientにmomentoのCacheClientを渡していたので、 LangChainjsにおいてはmomentoのうすーいwrapperのような役割のみとなっていることが見て取れると思います。

// https://github.com/langchain-ai/langchainjs/blob/main/langchain/src/stores/message/momento.ts
  /**
   * Adds a message to the cache.
   * @param message The BaseMessage instance to add to the cache.
   * @returns A Promise that resolves when the message has been added.
   */
  public async addMessage(message: BaseMessage): Promise<void> {
    const messageToAdd = JSON.stringify(
      mapChatMessagesToStoredMessages([message])[0]
    );

    const pushResponse = await this.client.listPushBack(
      this.cacheName,
      this.sessionId,
      messageToAdd,
      { ttl: this.sessionTtl }
    );
    if (pushResponse instanceof CacheListPushBack.Success) {
      // pass
    } else if (pushResponse instanceof CacheListPushBack.Error) {
      throw pushResponse.innerException();
    } else {
      throw new Error(`Unknown response type: ${pushResponse.toString()}`);
    }
  }

腕前で生きてるとWHYよりもHOWに執着しがち

はじめに

この投稿はポエムです。 技術的な記事を期待してる人はブラウザバックお願いします! なおこの文書は諸々の理由により、一部ぼかしたところがありますがご容赦ください。

TL;DR

  • 有名になった方法論は、目的が忘れられて、手段だけ広がることがある
  • 手段が「守らねばならぬルール」になって目的を達成できないことがよくある
  • 何か手法を取り入れるときは手段が目的化してないか気をつけねば

本文

先日、勉強会にて、とあるベンチャー企業の営業職の方とちょっと話す機会がありました。 その方は最近営業チームのマネージャーになられたとのことで、組織設計のお話をされまして「The Model的に営業のプロセスを細かく分割し、分業を進めて、KPIで成果を追えるようにした。現在は分業のメリットを感じている」 「The Model は The Goal を参考にしているとのことなので、ソフトウェアエンジニアの人も使える知見があるかも知れませんね」と言われました。

そのとき「あれ?」ってなりました。自分の記憶だとThe Goalは、生産プロセス全体をチェーンのようにつなげ、ボトルネックを発見し、 その改善に注力することでスループットを上げることを目的とする方法論だったからです。 なので、聞いた方法は The Goalの目的を達成するための方法論とは思えませんでした。 むしろ全員でボトルネックの業務をやる(もちろん仕組み・ツールで解決するまでは)方が、The Goalの目的に適うと思ったのです。 ネットで記事を漁ってみても分業に関する記事ばかり目に入るので、僕の理解に不安を覚えました。

インサイドセールスが自身のKPIを追い求めて熱量が低い顧客までフィールドセールスに渡す。フィールドセールスが無茶な獲得をした結果、カスタマーサクセスの対応コストが増加する。 全体最適化の仕組みを設けない分業は、このような問題を引き起こすことはよく知られてると思います。 そのため、The Goalを参考にした書籍が、主なメッセージに分業を置くとはどうしても思えませんでした。

違和感を感じて自分も書籍を読んだところ、なるほどーってなりました。

僕はThe Modelの最大の目的を「営業のプロセスを明確なステージに分割し、マーケティングのようにファネル分析を利用してボトルネックを特定・解決する」と読み取りました。 しかし、書籍の多くの部分で、分業による達成手法が重点的に説明されているので、「分業ってこんな良いことあるんだー」って情報を持ち帰った人が多いのだろうなと思いました。

同じようなことは、ソフトウェアエンジニアの「ドメイン駆動開発」でも経験しました。 あるベンチャー企業のビジネス職の方から、開発が大きく遅延してるので相談に乗って欲しいと言われ、ソフトウェアエンジニアの方にお話を聞いたときのことです。 「このプロジェクトはDDDで作るのが良いと思うので、こういう設計で開発します」と言われ見せられたのが、 ユビキタス言語やドメインモデルではなく、大量のService、Entity、Value Object、Domainなどのクラス群と、 「こういうときは Service ディレクトリにコードを置く。」などの大量のルールでした。 彼らが開発していたシステムはフロントもバックエンドもNext.js のみで作られており、 オブジェクトを加工せずに表示するのみのシンプルなシステムでした。 そこまで複雑なビジネスロジックでもなく、ドメインエキスパートと認識の共有もしないなかで、 DDDの戦術的設計が必要な理由がちょっと思いつきませんでした。

双方のケースを見て、なんでこんな不幸なことが発生したのかと考え、僕なりに出した仮説が双方HOWに囚われていた、ということです。 前者の営業マネージャーの方のケースだと、最近マネージャーになられたとのことなので、「マネージャーらしい仕事をしよう」と考え、組織的な管理方法というHOWに囚われたのかも知れません。 後者のエンジニアの方のケースだと、我々エンジニアの多くは技術力というHOWを対価にお金を貰っているお仕事なので、どうしてもHOWにとらわれがちなのだと思います。

自分もアジャイルに出会い、広めようとしたときに「守破離だ!」と言って同じミスをしました。 なので新しい手法を取り入れようとするときは、こういう問題に陥ってないかは意識したいなーって思った次第です。

OCR用のSaaSをアレコレ調査してみた

TL;DR

  • ちょっとプライベートでOCRを使ってみたかった
  • SaaS決定の判断基準は下記
    • 精度、費用、開発のしやすさ ( ライブラリの品質、インフラ設定の工数など )
  • 選定対象のSaaSは次の通り

選定基準

OCRの精度

サンプル4パターン手動で作成し、 OCR SaaSに読み込ませ、正しく読み込めていることを確認する。 ※ 実際に利用したサンプルは非公開です!

開発のしやすさ

OCR SaaSへ読み込ませる際は、GUIからではなくプログラムを作成して実行する。 その過程でOCRする際に必要な準備や、公式ドキュメントの充実、またTypeScriptで提供される型のクオリティなどを確認する

結果

Microsoft Form Recognizer を採用した。 結果を表にまとめたものを下記に記載する

翻訳精度 開発のしやすさ 費用
Microsoft Form Recognizer ○ ( ¥0.2 / 1 page )
Cloud Vision ○ (¥0.2 / 1 page )
Adobe PDF Service ✗ ( ¥6 / 1 page )

検証

Microsoft Form Recognizer

翻訳精度: ○

4パターンのPDFをすべてOCRしたが、一つも誤りはなかった

開発のしやすさ: ○

公式のライブラリはTypeScriptのサポートをしており、説明が十分で迷いなく実装できる。

費用: ¥0.2 / 1 page

価格 - Form Recognizer API | Microsoft Azure

Cloud Vision

翻訳精度: △

  • 罫線を | と読み取ってしまう
  • 1ヶ月 などの文字を 15月 と読み取る
  • 一部の文書を中国語と判断し、誤った文書化をする ( 再現性100% )

開発のしやすさ: △

  • pdf を読み込む際は、gcs(GCPのs3)を利用しなければならない
  • gcsで読み込んだ結果は gcs に出力される
  • png であれば、gcs を用いずにOCR可能
    • そのため利用するならば 見積書をs3だけでなくgcsにもコピーするか
    • pdfをpngに変換しなければならない
  • TypeScriptサポートが貧弱

参考資料: https://cloud.google.com/vision/docs/pdf?hl=ja

コスト: 1000ページあたり ¥206.19

料金 | Cloud Vision API | Google Cloud

Adobe Extract API

翻訳精度: ○

4パターンのPDFをすべてOCRしたが、一つも誤りはなかった

開発のしやすさ: ✗

  • PDFのままOCRができる
  • 出力は Zip で圧縮されてしまう
    • ※ 他のフォーマットもあるかも知れないが、ドキュメントが見つからず…
  • TypeScriptの対応がされておらず、開発者向けのドキュメントも貧弱
  • ちゃんと開発しようと思ったら直接ライブラリのコードをしっかり読み込むしかない

Untitled

コスト: 1ページあたり: ¥6.87

PDF Services API Licensing

【PO編】PO&SMをやめるので過去の学びをアレコレ書いてく

背景

  • 2年間くらい PO & Scrum Master とソフトウェアエンジニアを兼務したり色々あった
  • この度、ソフトウェアエンジニアに集中できることになった
  • せっかくなので、忘れる前に ( もうたくさん忘れたけど ) 書き出すことにした
  • この記事は、未来の自分が思い出すための記事である

全般

絶対に兼務しない

  • これだけ兼務まつりしてて言うのもあれだが
  • POに集中する期間は、SMとエンジニアとしての貯金が減ってるのを感じ
  • エンジニアに集中する期間は、POやエンジニアとしての貯金が減ってるのを実感する
  • 兼務状態は、常に何かしらの貯金が減る不安を抱えながら仕事することになる
  • スーパーマンでない限り、1つ1つのクオリティが落ちてしまい、よしんばなんとかなっても「なんとか破綻しない」程度になる
  • うまくいったとしても9割のケースは、誰かが荷物を持ってくれたから
  • なので 成功ケースだけを見て「兼務いけるやん」と思ってはいけない
  • 自分がうまくいった経験があったとしても「誰かのフォロワーシップに助けられただけ」 という意識を忘れてはいけない

Product Management 領域

仕事のほとんどは組織のチェンジマネジメント

  • Product Management の仕事。と考えると、顧客ヒアリングをして、市場調査して、仮説建てて、プロトタイプ作る。そんな感じの面白い仕事を想像してしまう。
  • だけど、それは仕事の中のほんの一部でしかない
  • プロダクトが変えるのは、顧客の業務だけでない。自組織の仕事の仕方も変わる
  • 顧客・自組織を問わず、業務のASIS/TOBEを描き、変化の過程を設計し、信頼を獲得し、より良い価値を提供するために変化させていく
  • 現状を適切に把握する上で、自ら営業として現地に行ったり、CSとして電話を取ったりすることもある
  • いつも慣れない領域に飛び込まねばならないので、ストレスフルなお仕事になる
  • けど、コンフォートゾーンにいるだけではまともな結果は出せないのでやるしかない

新しいソフトウェアじゃなくて新しい業務を作る

ベストプラクティスはWeb系企業で育まれている〜三菱重工業のDXを支える学びの姿勢〜 川口賢太郎、森岡周平(三菱重工業株式会社) | ITエンジニア向けのトレンド情報

仕事の大部分でPCを使わないような人達に、ソフトウェアの力で価値を届けていくという難しさがあると感じています。ただ、それこそとても挑戦しがいのある課題だと捉えています。

  • 昔、インタビューの中でこんなことを言った
  • これは今でも重要だと思っている
  • 顧客とソフトウェアのタッチポイントが多ければ多いほど、作る機能が増える
  • コード量、バグの可能性、顧客の負担、不満を言われる可能性も増える
  • ドリルと穴の話で、最高のドリルをつくるばかりに意識を向けず
  • 「穴を最小のコストで開ける」ってことに意識を向けたい
  • 顧客の利益を最大化しつつ、サービスとのタッチポイントを最小化することが重要
    • 例えば画面作らなくても、メールで済むものはそれで終わらせるとか
  • そのために、顧客の既存業務をしっかり理解し、ASIS/TOBEを描き、TOBEに至るまでの過程と、その都度のサービスのあり方を考えることが重要

ニーズが表面化するまで解決しない。でも準備はする

  • 「データが1万件になったら整理が必要なのでこの機能作りたい」
  • みたいな話が出ることがある
  • その時期は半年後だったり1年後だったりする
  • 今必要ない or ビジョン実現にMustでない機能は絶対に作らない を徹底しろ
  • 必要になったときの方が情報が増えてて適切な判断をできるだろうし
  • 今解くべき課題を確実に解くためにチームのキャパシティを割きたいし
  • 今、その課題に直面していないユーザーから納得感を得られないからだ
  • 前もって準備しても、課題に直面していないユーザーからは不満の声しか出てこない
  • 機能は作るよりも削るほうが難しい
  • Nice to Haveな機能を作ることに、なに1つ良いことはない
  • ステークホルダーの一時的な欲求を満たすだけだ
  • その代わり、課題が表面化したときに迅速に対応できる体制はつくっておく

PSFit完了まで人を増やさない

  • 一般的な会社において、人を遊ばせることに罪悪感を覚えない人はいない
  • だが、PSFitまでのフェーズにおいて、ひとはそんなに必要ない
  • PO、営業、デザイナ、場合によってマーケター (or コンサル) がいればなんとかなる
  • それ以上の人がチームにいたとき、POはそれらの人の仕事をつくることが求められる
  • またPOはステークホルダーの不安マネジメントも求められる
  • 結果、なかなかPSFitに集中できずズルズル余計なコストが嵩んでいく
  • ただし、各々が自走できる場合、不安マネジメントが不要な場合は人がいても良い
  • 開発チームは空気を読んで必要そうな機能の実験・検証・学習をしてくれてたりすると最高のスタートを切れたりする

ホームランを狙うか、アウトを避けるか決めておく

  • プロダクト開始時に、どちらのスタンスを取るか決める
  • ホームランを狙うなら、積極的にリスクを取れ
  • アウトを避けるのであれば、不要なリスクを取るな
  • コントロールしやすいリスクは、極力安全に倒せ
    • 人の採用・異動: 安易に人を増やすな。変えるな。もっと厳しい状態になることが多いぞ
    • 利用技術の選定: 社内で実績ある技術を採用する
    • ステークホルダーからシステムへの期待
      • プロダクト開始当初にちゃんと伝える。合意できなければそもそもやらない
    • 我々からステークホルダーへの期待
      • 極力期待しない。期待するときは何を期待するか。どうすれば実現できるか明示する
  • POとして意思決定する際は、何のリスクを取ってるか常に意識しろ

様々な道具で具体・抽象のアウトプットし続ける

  • 具体と抽象を行き来しなければ、有用な発見は得られない
  • 考えるだけでは意味がない。手を動かせ。手で考えろ
  • 抽象であたりをつけ、具体で検証しろ
  • 具体で違うとなったら、また抽象にもどれ
  • 例として、下記のような成果物が考えられる
  • 煮詰まったらDesign Sprint を頻繁に行っても良いだろう

デザイン

  • 具体的なアウトプットイメージは上記である
  • 1週間で1往復くらいがちょうど良い

資料作りを絶対サボらない

  • 顧客に説明に向かうとき、説明資料を持っていくことがある
  • 社内のステークホルダーに協力を依頼するとき、資料をつくることがある
  • この資料は絶対にサボってはいけない
  • 顧客においては、上司にシステム導入の話をする際に、自分が作った説明資料を使うことがあるし
  • 社内のステークホルダーも、周りに協力を得る際にその資料を使うことがある
  • PO自身が作った資料が、どんどん独り歩きしていく
  • そのため、明確で分かりやすい言葉を使い、多くの人が理解できるように、懇切丁寧な渾身の資料を作ることを常に意識することが大切

意思決定を人に委ねるな

  • プロダクトオーナーはプロダクトの意思決定者
  • 偉い人アドバイスをくれるだろうが、短絡的に飛びついてはいけない
  • アドバイスをくれた人に対して、自分が現状を100%伝えられてない可能がある
  • その場合のアドバイスは、必ずしも正しいとは限らない
  • 自分の中でロジックが説明できないことは、誰が言ったことでも通しちゃいけない
  • ステークホルダーがどんなにイヤと言ってもやらなければいけないこともある
  • 誰かに恨まれても、やるべきと思ったらやるという意志を持つ
  • そのとき、説明だけはちゃんとする。伝わらなくても、全力で伝える努力をする

最後に

  • 他にもいっぱい学びがあった気がするが... 今すぐ思い出せるのはこれくらい...
  • 思い出したら追記する

チームみんなが参加しやすくなるIaC設計の工夫

モチベーション

私は「インフラ」という言葉は、人によって受け取り方が大き違う言葉だと思ってます。 「よく分からないけど難しそうなので出来れば触りたくない」という人や、「ごく小数の人にしか触らせたくない」という人、 「IaaS、PaaSはインフラと認めない!」という人にも会ったことがあります。

「よく分からないなんとなく難しいもの」である「インフラ」は、多くの人に敬遠されがちなので、 中小規模の開発組織では1人のエンジニアがひっそりと孤独に作ってるケースがままあるのではないでしょうか。 私もやりたがる人があまりいないので、PMとインフラエンジニアを兼務することが多くありました。

この記事では、一人でひっそりとインフラを作ってた私が、触れる人を増やしたいと思って工夫したIaC設計のアイデアを記述しようと思います。

言葉の定義

インフラ

IaCで扱う「インフラ」の定義について。 言葉の定義を探していくつか書籍を漁ってみましたが、なかなかコレといった文書を見つけられませんでした。

OSを作るようなソフトウェアエンジニアからすればPCそのものがインフラですし、 Webアプリケーションをつくるソフトウェアエンジニアは、AWSGCPなどクラウド上で構築された、サーバー、ネットワーク、 ストレージなどのコンピューティングリソースをインフラと呼びがちです。 より一般向けの記事を読むと、社内のソフトウェアエンジニアが作成した管理画面や社内のCRMなどをITインフラと呼ぶこともあるようです。

ここでは「インフラ」という言葉の定義を、便宜的に「ソフトウェア開発に利用する、IaCで管理できるものすべて」とさせていただこうと思います。 なので、AWS等で構築するネットワークやDBだけでなく、エラー監視用のSentry、パフォーマンス監視に使うNewRelic、CI/CDとして利用するGitHub Actions、 その他様々なサービス群をインフラと呼ばせていただきます。

インフラのスタック

数々存在しているインフラリソースを任意のまとまりを、この記事では参考にした書籍にならって「スタック」と呼ばせていただきます。 この概念はCloudFormationのStackから来ており、スタックで管理されているリソースは一緒にデプロイされます。

設計について

設計の方針

私は「安全性」を最優先、「開発効率の維持」を2番めの優先事項としてIaCを設計します。

「安全性」は下記2点によって実現します

  • 設定の再現性・冪等性を担保するため、多少開発工数が掛かっても、できる限りのクラウドリソースをコード化
  • 障害発生時の影響範囲を限定的にするため、開発効率が多少下がっても初期からスタックを分割し、軽量なスタックを維持

「開発効率の維持」は下記2点によって実現します

  • 役割・技術要素軸でスタックを分割し、スタックを更新する上で必要な学習コストを軽減
  • スタックを小さく保ち、インフラタスクを複数人が並行して着手できる状態を維持

スタックの分割

上図のように、インフラを複数のスタックに分割します。

ここで簡単にいくつかのスタックについて説明します。 Appは、AWSなどクラウド上で構築しているネットワークやDB、バックエンドのサーバーを含む最も巨大なスタックです。 Frontは、フロントエンドのアプリケーションが構築されるスタックになります。Vercelなどの設定をこのスタックに記述することになります。 Monitoringは、監視に纏わる諸々を管理するスタックです。DatadogやCloudWatch等のインフラを管理します。

スタックは、ソフトウェアにおけるコンポーネントと同様の基準で分割しています。

再利用・リリース等価の原則 に従い、利用される単位とリリースの単位は等価になっており。 閉鎖性共通の原則 に従い、同じ理由、同じタイミングで変更されるものをまとめており。 非循環依存関係の原則 に従い、スタックの依存グラフは循環しない(必ず下の方向にのみ依存させる)ようになっています。

分割による狙い

分割の狙いは3つあります。

1つは 「変更容易性の確保」 です。 長大なスタックは把握しなければならないコード量と、変更による影響範囲が大きくなります。 すると、多くの人は怖がって触らないようになり、一部のエンジニアがお腹を痛めながら必要な修正をし続けることになります。 スタックを小さく保つことで、把握しなければならないコード量と変更による影響範囲を小さくし、高い頻度で継続して更新し続けられる状態を保ちます。

2つめは 「開発効率の維持」 です。 長大な1つのスタックは共同編集することができません。 インフラの変更を要する機能開発の要望が増えたとき、スタックが1つでは1人のエンジニアしか開発に参加できなくなります。 開発のボトルネックとなり、開発効率の低下を招きます。

3つめは 「開発組織の冗長性を高める」 ことです。 1つめの狙いに重なりますが、スタックの目的や影響範囲を限定することで、必然そのスタックに纏わる機能開発をする上で必要な前提知識も限定されます。 そうすることで開発に参加するための敷居を下げ、多くのメンバーがインフラの開発に参加できるような環境を整えます。

スタックの開発に参加する上で必要な知識の学習コストと、スタックを壊してしまったとき復旧にかかる時間をマッピングした図が下記のようになります。

右上にないものは学習コストが低い。 もしくは壊してもダメージ少ないものです。 それらはチームメンバーが開発に参加する上で心理的ハードルも低くなります。 そのため「初めての人は左下にあるものから入って、徐々に領域を広げて貰う」のような成長設計をしやすくなります。

IaCで採用するツールについて

私個人としては、スタックが適切に分割されていること。スタック間の依存が把握できていること。 この2点さえ守れるならば、IaCツールは何でも良いしと思っています。 どれか一つに統一する!と考えすぎず、各々のスタックの管理に適した(道具としての性質だけでなく、開発者の親和性含め) IaCツールを利用すれば良いのではないでしょうか。

私一人で利用する場合は、スタック間の依存関係を把握しやすく、リファクタリングも容易という理由でcdkを好んで使いますが、 下記の記事にも書きましたが terraformと比較すると cdk はそれなりに学習コストはあります。 なので、そこはチーム内で話し合ってチームに適したものを選べると良いかなと思います。

selmertsx.hatenablog.com

新しい技術を検討する際、PdMとして事前に決めておくこと

この資料の目的

自分のキャリアを振り返ってみると、PdMとソフトウェアエンジニア業を兼務することが多くありました。PdMとして業務していくなかで、エンジニアへのお願いの仕方を間違えて、想定よりも多く時間が掛かってしまうこともありました。その問題は、特に新しい技術を使って課題を解決しようとするときによくありました。

この記事では、新しい技術を試す際、現場で良くみるムダな仕事が発生しちゃってるケースを例に、そうならないよう自分が意識しているお願いの仕方を言語化してみようと思います。

よく見る良くない依頼の仕方

ケース1

  • PdM: 最近、XX という技術をよく聞くんだけどさ、あれでうちのサービスも良く出来ない?
  • エンジニア: うーん、とりあえず検証してみるんで、何ができたら嬉しいか教えてください
  • PdM: 何ができるか分からないからなー。とりあえず試してみてよ。良さそうだったらどう使うか考えるからさ。
  • エンジニア: やってみました!こういう結果になりました!
  • PdM: なるほどー、ありがとう!仕事で使いそうなことがあれば何か提案するわー。

ケース2

  • PdM: 最近、機械学習で不良品を見つける、ってことを他社でやってるらしいじゃん。うちの部署でもやってみたいからちょっと調べてみてよ
  • エンジニア: やってみました!僕たちの生産ラインで試したら、検知精度は75%みたいです
  • PdM: ちょっとうちの仕事では使えなそうだね〜。ありがとう

なんでこうなっちゃうの?

双方、エンジニアがあれこれ調査してくれましたが、成果につながらない、とても残念な結果になってしまいました。ちょっと極端に描いてしまいましたが、程度の差こそあれよく見る光景ではないでしょうか。( 僕は経験があります )

これらの問題は、扱おうとしている技術がどういうものか、そもそも何を解決したいのか、双方の解像度が低いときによく見られます。技術に関する理解が深ければ解くべき課題は定めやすいので混乱は生まれにくいですが、技術の理解が浅く、課題定義の能力も欠けていると現場はものすごいカオスになります。

そんなカオスな状態は下記の図の緑の状態にあります。こんな状況では「課題」というのもおこがましいので「何か」って言います。

これを望ましい状態に持っていくためには、「技術 」と「課題」の双方から確実性を高めていくアプローチが考えられますが、初手は「課題」の解像度を上げるべきです。たいていのケースで「課題」の解像度を上げるほうがコストが低いからです。

対処方法

ケース2で、PdMがもう少し状況を深く掘り下げ、下記の情報が分かっていたとします。

  • 出荷時の不良品は0.08%であり、これを目標値とする
  • 生産時の不良品は0.5%
  • 現在採用している不良品の検知システムは偽陰性が1%ある
  • そのため、最終的には人の目ですべて確認している
  • 確認の際は、月ウン百万の人件費が掛かっている
  • 不良品の見極めはルールベースで定められず
  • 見極めには深い知識と経験を持つ工員が必要になる
  • 現場は高齢化が進んでおり、恒常的に人を確保することが難しい
  • 熟練工の確保や、また業務時間の増加による従業員の不満増加という面で、事業継続リスクを抱えている
  • なので「検査工数の削減」「検査の簡易化」などの対処が求められる

※ 設定は新聞で読んだ事例を参考にしています。ので、現場の人から見ると間違いだらけかもですがご容赦ください

ここまで把握してれば、エンジニアに仕事を依頼する前に、下記のような意思決定をできるはずです。

意思決定を事前に下した場合、PdMからエンジニアへの依頼は下記のようになるでしょう。

  • 機械学習による不良品の検知を試して欲しい
  • 目的は不良品検査の工数削減
  • 精度が十分に高ければ、不良品検知の完全な自動化を検討するし
  • 精度が十分でなくとも偽陰性が基準値未満なら検査の工数を十分に下げられるはず。
  • なので、「精度」「偽陰性」「カットオフ値の調整有無」の3つの観点で調査してみて

そうすればエンジニアから、下記のような回答が得られるかも知れません。

  • 不良品の検知精度は75%程度なので、基準値に対して未達です
  • でも、偽陰性はx%でした
  • カットオフ値を調整すると精度は70%に下がりましたが、偽陰性は基準値を下回りました
  • 不良品と判断した製品をのみを抽出した場合、検査する部品数を大幅に下げられそうです
  • このシステムを導入したら、不良品検査に掛かる工数が30%程度下がる見込みです
  • ということでやっていきましょう!

こんな感じで、仕事を依頼する前に色々ちゃんと考えておくと、良い成果につながりやすいです。

さいごに

多くの人がアイデアを考える際、課題と解決策のセットを1つのアイデアとして捉えている。しかしアイデアは本来、課題と解決策に分離可能である。思いついたアイデアは一見きれいにまとまっているように見え、発想した本人も思い入れが強くなっていることも多いが、アイデア自体を課題と解決策に分離してそれぞれを検討することでさらに発想が広がることがある。 「プロダクトマネジメントのすべて」より引用。

ソフトウェア開発の経験が長いと、「課題と解決策を分けるなんて、当たり前じゃないか」と感じます。でも、意外と、驚くほどに、それらを混同して話し合ってしまっていることがあります。「うちのチームはそんなことないよ!」ってチームの場合は、きっと優秀な誰かが事前に整理してくれてるから。もしくは、チームメンバー全員が高いレベルにあって、議論が混ざらないからだと思います。誰かがそこらへん混同し始めると、意図せずみんなで混同し始めることが多くあります。そんなときに「あれ、なんかおかしいぞ」って気づいて、話を戻そうよって言える人は珍しいでしょう。ということで、PdMは「課題」と「解決策」を分け、「解決策」も複数パターン考慮した上でエンジニアに依頼すると、そこらへん混同せずに議論でき、業務がスムーズに進みます。

そもそもエンジニアにお願いする前にやれることもたくさんあります。

note.com

ということで、人に仕事をお願いする前に、自分で色々考えときましょう!という記事でしたー。