ローカルLLMで翻訳「結果」だけ欲しい

軽量なローカルLLMで翻訳するときに、意外と苦戦するのが**出力フォーマットの制御**です。翻訳結果だけを安定して得られる方法を模索しました。

:::note warn
執筆時点(2024年11月)での結果です。
:::

# 環境
- OS：Windows11
- 登場するモデル：Llama3.2(3b)、gemma2(9b) 



## 文章1
3b級の軽量モデルで以下の文章を翻訳してみようと思います。文章は生成AI(Copilot)に作ってもらいました。

>Today's LLMs are susceptible to prompt injections, jailbreaks, and other attacks that allow adversaries to overwrite a model's original instructions with their own malicious prompts. 
    In this work, we argue that one of the primary vulnerabilities underlying these attacks is that LLMs often consider system prompts (e.g., text from an application developer) to be the same priority as text from untrusted users and third parties. 
    To address this, we propose an instruction hierarchy that explicitly defines how models should behave when instructions of different priorities conflict. 
    We then propose an automated data generation method to demonstrate this hierarchical instruction following behavior, which teaches LLMs to selectively ignore lower-privileged instructions. 
    We apply this method to LLMs, showing that it drastically increases robustness-even for attack types not seen during training-while imposing minimal degradations on standard capabilities.

### モデル
Llama3.2:3b

### プロンプト
プロンプトにはしっかりと「翻訳結果」だけを出力するように記載します。今回の文章ではあまり関係無いですが、単語だけの場合にも対応できるように結果は一つだけ出力するようにルールを定めています。
```py
"""
次の文章を、以下のルールに従い、自然な日本語に翻訳して
    \n
    ルール：
    ・前置きや説明は省き、翻訳結果だけを出力する
    ・翻訳結果が複数ある場合は、最も適切な1つの翻訳結果だけを出力する
    \n
    文章:{docs}
"""
```

### 翻訳処理
上記のプロンプトで翻訳した結果を出力する最もシンプルな処理にします。処理フローは下のようになります。translate関数で処理した結果をresponse関数で取得するだけです。

![graph1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3948264/3257feee-0eb8-f13f-1d1e-732a4278691d.png)



<details open><summary>コード全文</summary>

```python:text1.py
from typing_extensions import Optional 
from langgraph.graph import StateGraph, END 
from pydantic import BaseModel, Field 
from langchain_ollama import ChatOllama

llm = ChatOllama(model="llama3.2:3b", temperature=0)

class State(BaseModel):
    original_text: Optional[str] = None
    translated_text: Optional[str] = None

def translate(state):
    translate_prompt = """
    次の文章を、以下のルールに従い、自然な日本語に翻訳して
    \n
    ルール：
    ・前置きや説明は省き、翻訳結果だけを出力する
    ・翻訳結果が複数ある場合は、最も適切な1つの翻訳結果だけを出力する
    \n
    文章:{docs}
    """
    translated_text = llm.invoke(translate_prompt. format(docs=state.original_text))
    return {
        'original_text': state.original_text,
        'translated_text': translated_text.content,
    }

def response(state):
    return state

if __name__ == "__main__":
    text1 ="""
    Today's LLMs are susceptible to prompt injections, jailbreaks, and other attacks that allow adversaries to overwrite a model's original instructions with their own malicious prompts. 
    In this work, we argue that one of the primary vulnerabilities underlying these attacks is that LLMs often consider system prompts (e.g., text from an application developer) to be the same priority as text from untrusted users and third parties. 
    To address this, we propose an instruction hierarchy that explicitly defines how models should behave when instructions of different priorities conflict. 
    We then propose an automated data generation method to demonstrate this hierarchical instruction following behavior, which teaches LLMs to selectively ignore lower-privileged instructions. 
    We apply this method to LLMs, showing that it drastically increases robustness-even for attack types not seen during training-while imposing minimal degradations on standard capabilities.
    """
    #Stateインスタンス
    graph_builder = StateGraph(State)

    #ノード（処理関数）
    graph_builder.add_node("translate", translate)
    graph_builder.add_node("response", response)

    # エッジ（処理の接続）
    graph_builder.add_edge("translate", "response")
    graph_builder.set_entry_point("translate") 
    graph_builder.set_finish_point("response")

    graph = graph_builder.compile()

    state = State(original_text=text1)
    result = graph.invoke(state) 
    print(result['translated_text'])

```
</details>

### 翻訳結果
>LLMsは今のところ、コマンドの入力やプログラムから得られたテキストを含むシステムのコマンドと、信頼性が低いユーザーからのコマンドを同等に優先することが容易な問題がある。したがって、この問題に対処するために、モデルが異なる優先度の指示間で対立する場合にどのように行動するかを明確に定義するように指示の階層を提案し、自動化されたデータ生成方法を提案した。これにより、LLMsは攻撃タイプがトレーニング時に見たものと異なる場合でも、強度が大幅に増すことができ、標準的な機能への影響が最小限になる。

しっかりと翻訳結果だけを出力できていました。大半の場面であればこれだけで十分そうですね。
    

## 文章2
もう少し複雑な文章で試してみようと思います。文章は[こちら](https://openai.com/policies/row-terms-of-use/)から引用しました。OpenAIの利用規約の内容の一部です。

>Copyright complaints
If you believe that your intellectual property rights have been infringed, please send notice to the address below or fill out this form. We may delete or disable content that we believe violates these Terms or is alleged to be infringing and will terminate accounts of repeat infringers where appropriate.
>
>OpenAI, L.L.C.
3180 18th St.
San Francisco, California 94110
Attn: General Counsel / Copyright Agent
>
>Written claims concerning copyright infringement must include the following information:
>
>A physical or electronic signature of the person authorized to act on behalf of the owner of the copyright interest
>
>A description of the copyrighted work that you claim has been infringed upon
>
>A description of where the allegedly infringing material is located on our site so we can find it
>
>Your address, telephone number, and e-mail address
>
>A statement by you that you have a good-faith belief that the disputed use is not authorized by the copyright owner, its agent, or the law
>
>A statement by you that the above information in your notice is accurate and, under penalty of perjury, that you are the copyright owner or authorized to act on the copyright owner’s behalf

### モデル
先程と同じモデル(Llama3.2:3b)を使用します

### 処理
文章1と同じなので割愛します

### 出力結果

>    <font color="Red">**以下は、ルールに従って翻訳された文章です。**</font>
>
>   Copyrightの不正使用に関する申し立て
>
>    不正使用があれば、知的財産権が侵害されていると言じている場合、上記のアドレスやこのフォームを送り出してください。侵害となる内容がこれらの規定に違反しているか、または法的許可なしで使用されていると疑われている場合、コンテンツを削除または無効化する権利があります。また、繰り返し侵害した場合のアカウントの終了についても注意してください。
>
>   オープナイ、L.L.C.
>    3180 18th St.
>    サンフランシスコ、カリフォルニア 94110
>    Attn: 総弁護士／コピーコール
>
>    著作権侵害に関する書面申し立ては、以下の情報が必要です。
>
>    * 著作権利者の代理人として行動できる人物の物理的または電子的な署名
>    * 贈られた作品が侵害されていると主張しているものの説明
>    * 侵害された作品がどこにありますか（サイト内）で見つけることができますか。
>    * 自分の住所、電話番号、メールアドレス
>    * 不正使用は著作権所有者の許可なしに認められていないと言じていることの証明
>    * 上記の情報が正確であることを確認し（法的制裁を受ける場合）、著作権所有者またはその代理人 behalfで>行動できる人物であることを証明する。
 
惜しいですね。翻訳はある程度うまく行えていそうですが、プロンプトを無視して冒頭に翻訳の説明が入りました。翻訳結果だけを淡々とまとめたい場面では厄介な挙動です。

とはいえ翻訳結果は出力されていますので、この出力から翻訳結果だけを抜き出すような処理を考えてみます。

llama3.2は出力を制御するための `with_structured_output` メソッドが実装されているようなので、このメソッドを用いて翻訳結果だけを抽出できるか試してみようと思います。

### 抽出処理
上記の翻訳結果と原文を合わせて入力し、そこから翻訳前後の文章を`Output`クラスに格納してもらおうと考えました。この抽出処理は`extract`関数で行います。

```py
class Output(BaseModel):
    original_text: str = Field(description="ここに原文を記入します", example="Mt Fuji is the highest mountain in japan")
    translated_text: str = Field(description="ここに翻訳文章を記入します", examp1e="日本で一番高い山は富士山です")

def extract(state):
    output = llm.with_structured_output(Output)
    extract_prompt = """
    以下の原文の日本語訳を探して
    \n
    原文:{original_text}
    \n
    日本語訳:{translated_text}
    """
    result = output.invoke(extract_prompt.format(original_text=state.original_text, translated_text=state.translated_text))
    return {'translated_text': result.translated_text}
```

<details open><summary>コード全文</summary>


```python:text2.py
from typing_extensions import Optional 
from langgraph.graph import StateGraph, END 
from pydantic import BaseModel, Field 
from langchain_ollama import ChatOllama

llm = ChatOllama(model="llama3.2:3b", temperature=0)

class State(BaseModel):
    original_text: Optional[str] = None
    translated_text: Optional[str] = None

class Output(BaseModel):
    original_text: str = Field(description="ここに原文を記入します", example="Mt Fuji is the highest mountain in japan")
    translated_text: str = Field(description="ここに翻訳文章を記入します", examp1e="日本で一番高い山は富士山です")

def extract(state):
    output = llm.with_structured_output(Output)
    extract_prompt = """
    以下の原文の日本語訳を探して
    \n
    原文:{original_text}
    \n
    日本語訳:{translated_text}
    """
    result = output.invoke(extract_prompt.format(original_text=state.original_text, translated_text=state.translated_text))
    return {'translated_text': result.translated_text}

def translate(state):
    translate_prompt = """
    次の文章を、以下のルールに従い、自然な日本語に翻訳して
    \n
    ルール：
    ・前置きや説明は省き、翻訳結果だけを出力する
    ・翻訳結果が複数ある場合は、最も適切な1つの翻訳結果だけを出力する
    \n
    文章:{docs}
    """
    translated_text = llm.invoke(translate_prompt. format(docs=state.original_text))
    return {
        'original_text': state.original_text,
        'translated_text': translated_text.content,
    }

def response(state):
    return state

if __name__ == "__main__":

    text2 = """
    Copyright complaints
    If you believe that your intellectual property rights have been infringed, please send notice to the address below or fill out this form. We may delete or disable content that we believe violates these Terms or is alleged to be infringing and will terminate accounts of repeat infringers where appropriate.

    OpenAI, L.L.C.
    3180 18th St.
    San Francisco, California 94110
    Attn: General Counsel / Copyright Agent

    Written claims concerning copyright infringement must include the following information:

    A physical or electronic signature of the person authorized to act on behalf of the owner of the copyright interest

    A description of the copyrighted work that you claim has been infringed upon

    A description of where the allegedly infringing material is located on our site so we can find it

    Your address, telephone number, and e-mail address

    A statement by you that you have a good-faith belief that the disputed use is not authorized by the copyright owner, its agent, or the law

    A statement by you that the above information in your notice is accurate and, under penalty of perjury, that you are the copyright owner or authorized to act on the copyright owner’s behalf
    """

    #Stateインスタンス
    graph_builder = StateGraph(State)

    #ノード（処理関数）
    graph_builder.add_node("translate", translate)
    graph_builder.add_node("response", response)

    # エッジ（処理の接続）
    graph_builder.add_edge("translate", "response")
    graph_builder.set_entry_point("translate") 
    graph_builder.set_finish_point("response")

    graph = graph_builder.compile()

    state = State(original_text=text2)
    result = graph.invoke(state)
    print(result['translated_text'])
```
</details>

### 処理
文章1の処理フローに上記の抽出処理を追加しました。`extract`関数は、`translate`関数で処理した結果から翻訳結果を抽出する役割を担います。

![graph2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3948264/32744b63-543b-5165-90ef-bc8297b5e58c.png)

### 出力結果(`extract`関数を追加)

>日明絏辻究に丼パールター・を調だ学室だき子だいただ子丼だ子丼だを機だ子丼だんカター・るきただいただ子丼だ子丼だを樅だ
    子丼だんカター・るきただいただ

残念な結果になりました。もはや翻訳結果とは呼べるものではないですね。プロンプトが複雑になってしまい、LLMが解釈できなくなったと考えられます。


### 最終手段
これまでの結果から、指示が複雑になるほどLLMがうまく処理できなくなることが分かりました。プロンプトや入力文章を調整するというのも手ですが、最もシンプルで強力な手段があります。それは**モデルの性能を上げる**ことです。

翻訳処理のみのシンプルな処理フロー(文章1で行った処理)で、パラメータ数の大きな gemma2:9b を使用した結果を見てみます。

>
>    著作権侵害に関する苦情
>
>    あなたの知的財産権が侵されたと信じる場合は、下記の住所に通知を送信するか、このフォームを記入してください。当社は、これらの利用規約に違反しているか、または侵害されているとされるコンテンツを削除または無効にし、繰り返し侵害者に対しては適切な措置をとります。
>
>    OpenAI, L.L.C.
>    3180 18th St.
>    San Francisco, California 94110
>    Attn: General Counsel / Copyright Agent
>
>    著作権侵害に関する書面による苦情には、以下の情報を含める必要があります。
>
>   * 著作権の所有者の代理人として行動できる人の物理的または電子的な署名
>    * 侵されたと主張する著作物の説明
>    * 当社のサイトで、その侵害物と思われるものがどこにあるかについての説明（当社が見つけられるように）
>    * あなたの住所、電話番号、電子メールアドレス
>    * 紛争利用が著作権所有者、代理人、または法律によって許可されていないと言じるあなたの声明
>    * 上記の情報は正確であり、証罪を狙すことを承知の上で、あなたが著作権所有者であるか、または著作権所有者の代理人として行動できることを表明するあなたの声明

うまくいきました！先ほどの軽量なLLMではプロンプトの指示を無視していましたが、比較的大きなパラメータ数をもつLLMでは翻訳結果だけを出力することができました。

<details open><summary>コード全文</summary>

```python:text2_gemma.py
from typing_extensions import Optional 
from langgraph.graph import StateGraph, END 
from pydantic import BaseModel, Field 
from langchain_ollama import ChatOllama 

llm = ChatOllama(model="gemma2:9b", temperature=0)
#llm = ChatOllama(model="llama3.2:3b", temperature=0)

class State(BaseModel):
    original_text: Optional[str] = None
    translated_text: Optional[str] = None

def translate(state):
    translate_prompt = """
    次の文章を、以下のルールに従い、自然な日本語に翻訳して
    \n
    ルール：
    ・前置きや説明は省き、翻訳結果だけを出力する
    ・翻訳結果が複数ある場合は、最も適切な1つの翻訳結果だけを出力する
    \n
    文章:{docs}
    """
    translated_text = llm.invoke(translate_prompt. format(docs=state.original_text))
    return {
        'original_text': state.original_text,
        'translated_text': translated_text.content,
    }

def response(state):
    return state

if __name__ == "__main__":
    text2 = """
    Copyright complaints
    If you believe that your intellectual property rights have been infringed, please send notice to the address below or fill out this form. We may delete or disable content that we believe violates these Terms or is alleged to be infringing and will terminate accounts of repeat infringers where appropriate.

    OpenAI, L.L.C.
    3180 18th St.
    San Francisco, California 94110
    Attn: General Counsel / Copyright Agent

    Written claims concerning copyright infringement must include the following information:

    A physical or electronic signature of the person authorized to act on behalf of the owner of the copyright interest

    A description of the copyrighted work that you claim has been infringed upon

    A description of where the allegedly infringing material is located on our site so we can find it

    Your address, telephone number, and e-mail address

    A statement by you that you have a good-faith belief that the disputed use is not authorized by the copyright owner, its agent, or the law

    A statement by you that the above information in your notice is accurate and, under penalty of perjury, that you are the copyright owner or authorized to act on the copyright owner’s behalf
    """
    
    #Stateインスタンス
    graph_builder = StateGraph(State)

    #ノード（処理関数）
    graph_builder.add_node("translate", translate)
    graph_builder.add_node("response", response)

    # エッジ（処理の接続）
    graph_builder.add_edge("translate", "response")
    graph_builder.set_entry_point("translate") 
    graph_builder.set_finish_point("response")

    graph = graph_builder.compile()

    state = State(original_text=text2)
    result = graph.invoke(state)
    print(result['translated_text'])
```
</details>


## まとめ
LLMの出力結果を制御するためには、制御するためのプロンプトを書くだけでなく、それを解釈できるようなモデルを使用する必要がありそうです。

パラメータ数の大きなモデルを使用できるならそれに越したことは無いですが、現実はコストや処理速度などの問題があります。これらの観点も踏まえて、過不足のないモデル選定を行っていきたいです。